@captiveorigins/onephylogeny-react 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,75 @@
1
+ Required Notice: Copyright 2026 Captive Origins (https://captiveorigins.com)
2
+
3
+ # PolyForm Noncommercial License 1.0.0
4
+
5
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncommercial Purposes
34
+
35
+ Any noncommercial purpose is a permitted purpose.
36
+
37
+ ## Personal Uses
38
+
39
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
40
+
41
+ ## Noncommercial Organizations
42
+
43
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
44
+
45
+ ## Fair Use
46
+
47
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
48
+
49
+ ## No Other Rights
50
+
51
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
52
+
53
+ ## Patent Defense
54
+
55
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
56
+
57
+ ## Violations
58
+
59
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
60
+
61
+ ## No Liability
62
+
63
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
64
+
65
+ ## Definitions
66
+
67
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
68
+
69
+ **You** refers to the individual or entity agreeing to these terms.
70
+
71
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
72
+
73
+ **Your licenses** are all the licenses granted to you for the software under these terms.
74
+
75
+ **Use** means anything you do with the software requiring one of your licenses.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # @captiveorigins/onephylogeny-react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-renderable views of sample results, panel coverage, validation, run logs and outgroup suggestions, with a compact lineage card for record pages.
4
+
5
+ Import `@captiveorigins/onephylogeny-react/styles.css`. Colors are CSS variables prefixed `--ophy-`.
6
+
7
+ See the [repository](https://github.com/CaptiveOrigins/onephylogeny) for documentation.
@@ -0,0 +1,19 @@
1
+ import * as react from 'react';
2
+
3
+ /** Makes tree markup open correctly on its own, in a browser or a drawing program. */
4
+ declare function standaloneSvg(markup: string): string;
5
+ type TreeCanvasProps = {
6
+ newick: string;
7
+ labelFor?: (tip: string) => string;
8
+ thresholds?: number[];
9
+ /** CSS height of the canvas. */
10
+ height?: string;
11
+ /** Where a saved SVG goes. By default the browser downloads it. */
12
+ onSaveSvg?: (svg: string) => void | Promise<void>;
13
+ /** How to print. By default `window.print()`, which does nothing in some Mac web views. */
14
+ onPrint?: () => void | Promise<void>;
15
+ };
16
+ /** The tree on a canvas: drag to pan, Ctrl/⌘ + wheel or pinch to zoom, `+` `-` `0` keys, Fit, Download SVG, Print (chart only). */
17
+ declare function TreeCanvas({ newick, labelFor, thresholds, height, onSaveSvg, onPrint }: TreeCanvasProps): react.JSX.Element;
18
+
19
+ export { TreeCanvas, type TreeCanvasProps, standaloneSvg };
package/dist/canvas.js ADDED
@@ -0,0 +1,132 @@
1
+ "use client";
2
+ import {
3
+ Icon
4
+ } from "./chunk-YY252G72.js";
5
+ import {
6
+ TreeFigure
7
+ } from "./chunk-77BOVPO3.js";
8
+
9
+ // src/canvas.tsx
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ var MIN_ZOOM = 0.25;
13
+ var MAX_ZOOM = 8;
14
+ var clampZoom = (k) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
15
+ var STANDALONE_STYLE = "<style>.ophy-tree-edge{fill:none;stroke:#91645d;stroke-width:1.2}.ophy-tree-tip{font:11px sans-serif;fill:#1d2330}.ophy-tree-support{font:8.5px sans-serif;fill:#5b6577}.ophy-tree-supported{fill:#1d6b47;font-weight:700}</style>";
16
+ function standaloneSvg(markup) {
17
+ const open = markup.indexOf(">");
18
+ const head = markup.slice(0, open).replace(/\sstyle="[^"]*"/, "");
19
+ return `${head.includes("xmlns=") ? head : head.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"')}>${STANDALONE_STYLE}${markup.slice(open + 1)}`;
20
+ }
21
+ function downloadInBrowser(svg) {
22
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
23
+ const a = document.createElement("a");
24
+ a.href = url;
25
+ a.download = "tree.svg";
26
+ a.click();
27
+ URL.revokeObjectURL(url);
28
+ }
29
+ function TreeCanvas({ newick, labelFor, thresholds, height = "32rem", onSaveSvg, onPrint }) {
30
+ const canvas = useRef(null);
31
+ const [{ x, y, k }, setCamera] = useState({ x: 0, y: 0, k: 1 });
32
+ const drag = useRef(null);
33
+ const zoomBy = useCallback((factor, at) => {
34
+ setCamera((c) => {
35
+ const rect = canvas.current?.getBoundingClientRect();
36
+ const ax = at?.x ?? (rect ? rect.width / 2 : 0);
37
+ const ay = at?.y ?? (rect ? rect.height / 2 : 0);
38
+ const nk = clampZoom(c.k * factor);
39
+ return { k: nk, x: ax - (ax - c.x) * nk / c.k, y: ay - (ay - c.y) * nk / c.k };
40
+ });
41
+ }, []);
42
+ const fit = useCallback(() => {
43
+ setCamera((c) => {
44
+ const el = canvas.current;
45
+ const svg = el?.querySelector("svg");
46
+ if (!el || !svg) return { x: 0, y: 0, k: 1 };
47
+ const fullHeight = svg.getBoundingClientRect().height / c.k;
48
+ return { x: 0, y: 0, k: fullHeight ? clampZoom(Math.min(1, el.clientHeight / fullHeight)) : 1 };
49
+ });
50
+ }, []);
51
+ useEffect(() => {
52
+ const el = canvas.current;
53
+ if (!el) return;
54
+ const wheel = (e) => {
55
+ if (!e.ctrlKey && !e.metaKey) return;
56
+ e.preventDefault();
57
+ const rect = el.getBoundingClientRect();
58
+ zoomBy(Math.exp(-e.deltaY / 200), { x: e.clientX - rect.left, y: e.clientY - rect.top });
59
+ };
60
+ el.addEventListener("wheel", wheel, { passive: false });
61
+ return () => el.removeEventListener("wheel", wheel);
62
+ }, [zoomBy]);
63
+ useEffect(() => () => document.documentElement.removeAttribute("data-ophy-printing"), []);
64
+ const down = (e) => {
65
+ if (e.target.closest(".ophy-floating")) return;
66
+ drag.current = { id: e.pointerId, px: e.clientX, py: e.clientY };
67
+ e.currentTarget.setPointerCapture(e.pointerId);
68
+ };
69
+ const move = (e) => {
70
+ const d = drag.current;
71
+ if (!d || d.id !== e.pointerId) return;
72
+ const dx = e.clientX - d.px;
73
+ const dy = e.clientY - d.py;
74
+ drag.current = { ...d, px: e.clientX, py: e.clientY };
75
+ setCamera((c) => ({ ...c, x: c.x + dx, y: c.y + dy }));
76
+ };
77
+ const up = () => {
78
+ drag.current = null;
79
+ };
80
+ const keys = (e) => {
81
+ if (e.key === "+" || e.key === "=") zoomBy(1.25);
82
+ else if (e.key === "-") zoomBy(0.8);
83
+ else if (e.key === "0") fit();
84
+ else return;
85
+ e.preventDefault();
86
+ };
87
+ const save = () => {
88
+ const svg = canvas.current?.querySelector("svg.ophy-tree");
89
+ if (!svg) return;
90
+ const text = standaloneSvg(svg.outerHTML);
91
+ if (onSaveSvg) void onSaveSvg(text);
92
+ else downloadInBrowser(text);
93
+ };
94
+ const print = () => {
95
+ document.documentElement.setAttribute("data-ophy-printing", "");
96
+ if (onPrint) void onPrint();
97
+ else window.print();
98
+ };
99
+ return /* @__PURE__ */ jsxs(
100
+ "div",
101
+ {
102
+ ref: canvas,
103
+ className: "ophy-canvas",
104
+ style: { height },
105
+ tabIndex: 0,
106
+ role: "group",
107
+ "aria-label": "Tree canvas. Drag to pan. Press plus or minus to zoom, and 0 to fit.",
108
+ onPointerDown: down,
109
+ onPointerMove: move,
110
+ onPointerUp: up,
111
+ onPointerCancel: up,
112
+ onKeyDown: keys,
113
+ children: [
114
+ /* @__PURE__ */ jsx("div", { className: "ophy-canvas-content", style: { transform: `translate(${x}px, ${y}px) scale(${k})` }, children: /* @__PURE__ */ jsx(TreeFigure, { newick, ...labelFor ? { labelFor } : {}, ...thresholds ? { thresholds } : {} }) }),
115
+ /* @__PURE__ */ jsxs("div", { className: "ophy-floating", children: [
116
+ /* @__PURE__ */ jsx(ToolButton, { icon: "zoomIn", label: "Zoom in", onClick: () => zoomBy(1.25) }),
117
+ /* @__PURE__ */ jsx(ToolButton, { icon: "zoomOut", label: "Zoom out", onClick: () => zoomBy(0.8) }),
118
+ /* @__PURE__ */ jsx(ToolButton, { icon: "fit", label: "Fit", onClick: fit }),
119
+ /* @__PURE__ */ jsx(ToolButton, { icon: "download", label: "Download SVG", onClick: save }),
120
+ /* @__PURE__ */ jsx(ToolButton, { icon: "print", label: "Print", onClick: print })
121
+ ] })
122
+ ]
123
+ }
124
+ );
125
+ }
126
+ function ToolButton({ icon, label, onClick }) {
127
+ return /* @__PURE__ */ jsx("button", { type: "button", "aria-label": label, "data-tip": label, onClick, children: /* @__PURE__ */ jsx(Icon, { name: icon }) });
128
+ }
129
+ export {
130
+ TreeCanvas,
131
+ standaloneSvg
132
+ };
@@ -0,0 +1,456 @@
1
+ // src/index.tsx
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ var STATUS = {
4
+ consistent: { label: "Consistent", mark: "\u25CF" },
5
+ excluded_within_panel: { label: "Excluded within this panel", mark: "\xD7" },
6
+ not_evaluable: { label: "Not evaluable", mark: "\u2013" },
7
+ inconclusive: { label: "Inconclusive", mark: "?" }
8
+ };
9
+ var statusLabel = (status, labels) => labels?.[status] ?? STATUS[status].label;
10
+ function StatusBadge({ status, labels }) {
11
+ return /* @__PURE__ */ jsxs("span", { className: `ophy-status ophy-status-${status}`, children: [
12
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: STATUS[status].mark }),
13
+ " ",
14
+ statusLabel(status, labels)
15
+ ] });
16
+ }
17
+ var groupLabel = (panel, id) => panel.groups.find((g) => g.id === id)?.label ?? id;
18
+ var ORDER = ["consistent", "inconclusive", "excluded_within_panel", "not_evaluable"];
19
+ function summarizeResult(result, panel) {
20
+ const named = (status) => result.findings.filter((f) => f.status === status).map((f) => groupLabel(panel, f.group));
21
+ const consistent = named("consistent");
22
+ const untested = result.candidatesNotEvaluated.map((g) => groupLabel(panel, g));
23
+ if (result.findings.every((f) => f.status === "not_evaluable")) return "Not enough comparable data to evaluate any group";
24
+ const unknown = untested.length ? ` Not evaluated: ${untested.join(", ")}.` : "";
25
+ if (consistent.length === 1) return `Consistent with ${consistent[0]}.${unknown}`;
26
+ if (consistent.length) return `Can't separate: ${consistent.join(", ")}.${unknown}`;
27
+ if (result.novelty.closest) return `No evaluated group fits. Closest is ${groupLabel(panel, result.novelty.closest)}, which may mean an unsampled population.${unknown}`;
28
+ return "Inconclusive against this panel";
29
+ }
30
+ function CoverageBars({ panel, queryAligned, queryLabel = "This sample" }) {
31
+ const L = panel.reference.length;
32
+ const rowH = 26;
33
+ const labelW = 190;
34
+ const W = 700;
35
+ const x = (p) => labelW + p / L * (W - labelW - 8);
36
+ const queryWindows = [];
37
+ if (queryAligned) {
38
+ let start = -1;
39
+ for (let i = 0; i <= queryAligned.length; i++) {
40
+ const has = i < queryAligned.length && /[ACGTRYSWKMBDHVacgt]/.test(queryAligned[i]);
41
+ if (has && start < 0) start = i;
42
+ if (!has && start >= 0) {
43
+ queryWindows.push({ start, end: i });
44
+ start = -1;
45
+ }
46
+ }
47
+ }
48
+ const rows = [...queryAligned ? [{ id: "__q", label: queryLabel, windows: queryWindows }] : [], ...panel.groups.map((g) => ({ id: g.id, label: g.label, windows: g.coverage.flatMap((c) => c.columnsAtDepth.map(([start, end]) => ({ start, end }))) }))];
49
+ const H = rows.length * rowH + 24;
50
+ return /* @__PURE__ */ jsxs("svg", { className: "ophy-coverage", viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `Coverage of each group across ${L} reference columns`, children: [
51
+ rows.map((r, i) => /* @__PURE__ */ jsxs("g", { transform: `translate(0 ${i * rowH})`, children: [
52
+ /* @__PURE__ */ jsx("text", { x: labelW - 8, y: 16, textAnchor: "end", className: "ophy-coverage-label", children: r.label }),
53
+ /* @__PURE__ */ jsx("rect", { x: x(0), y: 7, width: x(L) - x(0), height: 12, rx: 3, className: "ophy-coverage-track" }),
54
+ r.windows.map((w) => /* @__PURE__ */ jsx("rect", { x: x(w.start), y: 7, width: Math.max(2, x(w.end) - x(w.start)), height: 12, rx: 3, className: r.id === "__q" ? "ophy-coverage-query" : "ophy-coverage-group", children: /* @__PURE__ */ jsx("title", { children: `${r.label}: columns ${w.start + 1} to ${w.end}` }) }, w.start))
55
+ ] }, r.id)),
56
+ /* @__PURE__ */ jsx("text", { x: x(0), y: H - 6, className: "ophy-coverage-axis", children: "1" }),
57
+ /* @__PURE__ */ jsx("text", { x: x(L), y: H - 6, textAnchor: "end", className: "ophy-coverage-axis", children: L })
58
+ ] });
59
+ }
60
+ var PAIR_REASON = {
61
+ "decisive-sites": "Decisive sites agree",
62
+ "group-not-evaluable": "At least one group can't be compared here",
63
+ "no-sites-covered": "This sequence covers no site that separates them",
64
+ "too-few-decisive-sites": "Too few separating sites covered",
65
+ "sites-disagree": "Separating sites disagree",
66
+ "thin-group": "Sites favor one side, but the other has too few independent sites to rule out"
67
+ };
68
+ var PAIR_STATE = { resolved: "Resolved", not_resolved: "Not resolved", not_evaluable: "Not evaluable" };
69
+ function PairTable({ result, panel }) {
70
+ if (!result.pairs.length) return null;
71
+ return /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
72
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
73
+ /* @__PURE__ */ jsx("th", { children: "Pair" }),
74
+ /* @__PURE__ */ jsx("th", { children: "State" }),
75
+ /* @__PURE__ */ jsx("th", { children: "Sites covered" }),
76
+ /* @__PURE__ */ jsx("th", { children: "Why" })
77
+ ] }) }),
78
+ /* @__PURE__ */ jsx("tbody", { children: result.pairs.map((p) => /* @__PURE__ */ jsxs("tr", { children: [
79
+ /* @__PURE__ */ jsxs("th", { scope: "row", children: [
80
+ groupLabel(panel, p.a),
81
+ " / ",
82
+ groupLabel(panel, p.b)
83
+ ] }),
84
+ /* @__PURE__ */ jsxs("td", { children: [
85
+ PAIR_STATE[p.state],
86
+ p.favors ? `: favors ${groupLabel(panel, p.favors)}` : ""
87
+ ] }),
88
+ /* @__PURE__ */ jsxs("td", { children: [
89
+ p.sitesCovered,
90
+ " (",
91
+ p.forA,
92
+ " and ",
93
+ p.forB,
94
+ p.neither ? `, ${p.neither} neither` : "",
95
+ ")"
96
+ ] }),
97
+ /* @__PURE__ */ jsx("td", { children: PAIR_REASON[p.reasonCode] })
98
+ ] }, `${p.a}|${p.b}`)) })
99
+ ] });
100
+ }
101
+ function ResultView({ result, panel, queryAligned, labels }) {
102
+ const findings = [...result.findings].sort((a, b) => ORDER.indexOf(a.status) - ORDER.indexOf(b.status));
103
+ return /* @__PURE__ */ jsxs("section", { className: "ophy-result", children: [
104
+ /* @__PURE__ */ jsx("h3", { children: summarizeResult(result, panel) }),
105
+ /* @__PURE__ */ jsxs("p", { className: "ophy-muted", children: [
106
+ "Tested against ",
107
+ panel.id,
108
+ " version ",
109
+ panel.version,
110
+ ", over ",
111
+ result.overlap,
112
+ " columns."
113
+ ] }),
114
+ result.outOfValidatedScope.length > 0 && /* @__PURE__ */ jsxs("p", { className: "ophy-warning", children: [
115
+ "Outside what this panel has been validated for: ",
116
+ result.outOfValidatedScope.join(". "),
117
+ "."
118
+ ] }),
119
+ /* @__PURE__ */ jsx(CoverageBars, { panel, ...queryAligned ? { queryAligned } : {} }),
120
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
121
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
122
+ /* @__PURE__ */ jsx("th", { children: "Group" }),
123
+ /* @__PURE__ */ jsx("th", { children: "Status" }),
124
+ /* @__PURE__ */ jsx("th", { children: "Why" }),
125
+ /* @__PURE__ */ jsx("th", { children: "Nearest records" })
126
+ ] }) }),
127
+ /* @__PURE__ */ jsx("tbody", { children: findings.map((f) => /* @__PURE__ */ jsxs("tr", { children: [
128
+ /* @__PURE__ */ jsx("th", { scope: "row", children: groupLabel(panel, f.group) }),
129
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(StatusBadge, { status: f.status, ...labels ? { labels } : {} }) }),
130
+ /* @__PURE__ */ jsx("td", { children: f.reason }),
131
+ /* @__PURE__ */ jsx("td", { children: f.evidence.join(", ") || "\u2013" })
132
+ ] }, f.group)) })
133
+ ] }),
134
+ /* @__PURE__ */ jsx(PairTable, { result, panel }),
135
+ result.novelty.neitherSideSites > 0 && /* @__PURE__ */ jsxs("p", { className: "ophy-muted", children: [
136
+ result.novelty.neitherSideSites,
137
+ " separating sites hold a base matching neither group."
138
+ ] }),
139
+ result.caveats.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
140
+ /* @__PURE__ */ jsx("h4", { children: "What this can't tell you" }),
141
+ /* @__PURE__ */ jsx("ul", { className: "ophy-caveats", children: result.caveats.map((c) => /* @__PURE__ */ jsx("li", { children: c }, c)) })
142
+ ] })
143
+ ] });
144
+ }
145
+ function LineageCard({ result, panel, marker, title = "Maternal lineage", note = "Not evidence of where this animal was collected.", href }) {
146
+ return /* @__PURE__ */ jsxs("aside", { className: "ophy-card", children: [
147
+ /* @__PURE__ */ jsx("div", { className: "ophy-card-title", children: title }),
148
+ /* @__PURE__ */ jsx("div", { className: "ophy-card-value", children: summarizeResult(result, panel) }),
149
+ /* @__PURE__ */ jsxs("div", { className: "ophy-muted", children: [
150
+ "Based on ",
151
+ marker,
152
+ ", compared with panel version ",
153
+ panel.version,
154
+ ". ",
155
+ note
156
+ ] }),
157
+ href && /* @__PURE__ */ jsx("a", { href, children: "See how we reached this" })
158
+ ] });
159
+ }
160
+ function PanelSummary({ panel }) {
161
+ return /* @__PURE__ */ jsxs("section", { children: [
162
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
163
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
164
+ /* @__PURE__ */ jsx("th", { children: "Group" }),
165
+ /* @__PURE__ */ jsx("th", { children: "Region" }),
166
+ /* @__PURE__ */ jsx("th", { children: "Records" }),
167
+ /* @__PURE__ */ jsx("th", { children: "Can show location" }),
168
+ /* @__PURE__ */ jsx("th", { children: "Comparable in" }),
169
+ /* @__PURE__ */ jsx("th", { children: "Records still needed" })
170
+ ] }) }),
171
+ /* @__PURE__ */ jsx("tbody", { children: panel.groups.map((g) => /* @__PURE__ */ jsxs("tr", { children: [
172
+ /* @__PURE__ */ jsx("th", { scope: "row", children: g.label }),
173
+ /* @__PURE__ */ jsx("td", { children: g.region ?? "\u2013" }),
174
+ /* @__PURE__ */ jsx("td", { children: g.evidence.length }),
175
+ /* @__PURE__ */ jsx("td", { children: g.evidence.filter((e) => e.use === "lineage-and-location").length }),
176
+ /* @__PURE__ */ jsx("td", { children: g.coverage.filter((c) => c.columnsAtDepth.length).map((c) => c.window).join(", ") || "Nowhere yet" }),
177
+ /* @__PURE__ */ jsx("td", { children: g.coverage.filter((c) => c.recordsNeeded).map((c) => `${c.recordsNeeded} in ${c.window}`).join(", ") || "\u2013" })
178
+ ] }, g.id)) })
179
+ ] }),
180
+ /* @__PURE__ */ jsx(CoverageBars, { panel }),
181
+ /* @__PURE__ */ jsx("h4", { children: "What separates each pair" }),
182
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
183
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
184
+ /* @__PURE__ */ jsx("th", { children: "Pair" }),
185
+ /* @__PURE__ */ jsx("th", { children: "Window" }),
186
+ /* @__PURE__ */ jsx("th", { children: "Sites" }),
187
+ /* @__PURE__ */ jsx("th", { children: "Records" }),
188
+ /* @__PURE__ */ jsx("th", { children: "Sites per side" })
189
+ ] }) }),
190
+ /* @__PURE__ */ jsx("tbody", { children: panel.pairs.map((p) => /* @__PURE__ */ jsxs("tr", { children: [
191
+ /* @__PURE__ */ jsxs("th", { scope: "row", children: [
192
+ groupLabel(panel, p.a),
193
+ " / ",
194
+ groupLabel(panel, p.b)
195
+ ] }),
196
+ /* @__PURE__ */ jsx("td", { children: p.window }),
197
+ /* @__PURE__ */ jsx("td", { children: p.state === "not_evaluable" ? "Not evaluable" : p.sites.length }),
198
+ /* @__PURE__ */ jsxs("td", { children: [
199
+ p.records.a,
200
+ " and ",
201
+ p.records.b
202
+ ] }),
203
+ /* @__PURE__ */ jsxs("td", { children: [
204
+ p.blocks.a,
205
+ " and ",
206
+ p.blocks.b
207
+ ] })
208
+ ] }, `${p.a}|${p.b}|${p.window}`)) })
209
+ ] }),
210
+ panel.notCovered.length > 0 && /* @__PURE__ */ jsxs("p", { children: [
211
+ /* @__PURE__ */ jsx("strong", { children: "Not covered:" }),
212
+ " ",
213
+ panel.notCovered.join(", ")
214
+ ] }),
215
+ /* @__PURE__ */ jsxs("p", { className: "ophy-muted", children: [
216
+ "Checksum ",
217
+ panel.checksum
218
+ ] })
219
+ ] });
220
+ }
221
+ var pct = (a, b) => b ? `${a} of ${b}` : "\u2013";
222
+ var SCHEME = {
223
+ "leave-one-record-out": "Each record (leak check only)",
224
+ "leave-one-haplotype-out": "Each exact haplotype",
225
+ "leave-one-block-out": "Each site (the default claim)",
226
+ "leave-one-study-out": "Each study",
227
+ "leave-one-group-out": "Each whole group"
228
+ };
229
+ function ClaimsView({ claims, panel }) {
230
+ const label = (id) => panel ? groupLabel(panel, id) : id;
231
+ const passed = claims.filter((c) => c.passed);
232
+ return /* @__PURE__ */ jsxs("section", { children: [
233
+ /* @__PURE__ */ jsx("h4", { children: "Claims tested" }),
234
+ /* @__PURE__ */ jsxs("p", { children: [
235
+ passed.length,
236
+ " of ",
237
+ claims.length,
238
+ " passed every gate. All are exploratory until tested on data that didn't shape this method."
239
+ ] }),
240
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
241
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
242
+ /* @__PURE__ */ jsx("th", { children: "Pair" }),
243
+ /* @__PURE__ */ jsx("th", { children: "Window" }),
244
+ /* @__PURE__ */ jsx("th", { children: "Sites" }),
245
+ /* @__PURE__ */ jsx("th", { children: "Result" }),
246
+ /* @__PURE__ */ jsx("th", { children: "Stopped by" })
247
+ ] }) }),
248
+ /* @__PURE__ */ jsx("tbody", { children: claims.map((c) => {
249
+ const failed = c.gates.filter((g) => g.ran && !g.passed);
250
+ return /* @__PURE__ */ jsxs("tr", { children: [
251
+ /* @__PURE__ */ jsxs("th", { scope: "row", children: [
252
+ label(c.a),
253
+ " / ",
254
+ label(c.b)
255
+ ] }),
256
+ /* @__PURE__ */ jsx("td", { children: c.window }),
257
+ /* @__PURE__ */ jsxs("td", { children: [
258
+ c.sites.a,
259
+ " and ",
260
+ c.sites.b
261
+ ] }),
262
+ /* @__PURE__ */ jsx("td", { children: c.passed ? `Passed, ${c.minColumns}+ columns` : "Not validated" }),
263
+ /* @__PURE__ */ jsx("td", { children: failed.map((g) => g.detail).join(". ") || "\u2013" })
264
+ ] }, `${c.a}|${c.b}|${c.window}`);
265
+ }) })
266
+ ] })
267
+ ] });
268
+ }
269
+ function ValidationView({ blocks, groups, ladder, claims, panel }) {
270
+ const noEvidence = groups.groups.reduce((sum, g) => sum + g.notEvaluable, 0);
271
+ return /* @__PURE__ */ jsxs("section", { children: [
272
+ /* @__PURE__ */ jsx("h4", { children: "Holding out each sampling site" }),
273
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
274
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
275
+ /* @__PURE__ */ jsx("th", { children: "Group" }),
276
+ /* @__PURE__ */ jsx("th", { children: "Sites" }),
277
+ /* @__PURE__ */ jsx("th", { children: "True group consistent" }),
278
+ /* @__PURE__ */ jsx("th", { children: "Only the true group" }),
279
+ /* @__PURE__ */ jsx("th", { children: "Only the true group, all others compared" }),
280
+ /* @__PURE__ */ jsx("th", { children: "True group excluded" }),
281
+ /* @__PURE__ */ jsx("th", { children: "Inconclusive" }),
282
+ /* @__PURE__ */ jsx("th", { children: "Not evaluable" })
283
+ ] }) }),
284
+ /* @__PURE__ */ jsx("tbody", { children: blocks.groups.map((g) => {
285
+ const s = blocks.sites?.find((x) => x.group === g.group);
286
+ return /* @__PURE__ */ jsxs("tr", { children: [
287
+ /* @__PURE__ */ jsx("th", { scope: "row", children: g.group }),
288
+ /* @__PURE__ */ jsx("td", { children: s ? `${s.sitesRight} of ${s.sites} right${s.interval ? `, ${s.interval[0].toFixed(2)} to ${s.interval[1].toFixed(2)}` : ""}` : "\u2013" }),
289
+ /* @__PURE__ */ jsx("td", { children: pct(g.includedTruth, g.tested) }),
290
+ /* @__PURE__ */ jsx("td", { children: pct(g.onlyTruth, g.tested) }),
291
+ /* @__PURE__ */ jsx("td", { children: pct(g.onlyTruthAllEvaluated, g.tested) }),
292
+ /* @__PURE__ */ jsx("td", { children: pct(g.excludedTruth, g.tested) }),
293
+ /* @__PURE__ */ jsx("td", { children: pct(g.inconclusive, g.tested) }),
294
+ /* @__PURE__ */ jsx("td", { children: pct(g.notEvaluable, g.tested) })
295
+ ] }, g.group);
296
+ }) })
297
+ ] }),
298
+ blocks.untestedGroups.length > 0 && /* @__PURE__ */ jsxs("p", { className: "ophy-warning", children: [
299
+ "Not tested, because all their records come from one site: ",
300
+ blocks.untestedGroups.join(", "),
301
+ "."
302
+ ] }),
303
+ blocks.diagnostics.map((d) => /* @__PURE__ */ jsx("p", { className: "ophy-warning", children: d.message }, d.code)),
304
+ /* @__PURE__ */ jsx("h4", { children: "Removing each whole group" }),
305
+ /* @__PURE__ */ jsxs("p", { children: [
306
+ groups.falseConsistent ?? 0,
307
+ " of ",
308
+ groups.tested,
309
+ " records were wrongly called consistent with a remaining group. Lower is better, and this is how the panel behaves when a sample's true population is missing.",
310
+ noEvidence > 0 ? ` ${noEvidence} of them couldn't be compared with any remaining group, so they say nothing either way.` : ""
311
+ ] }),
312
+ ladder && ladder.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
313
+ /* @__PURE__ */ jsx("h4", { children: "What was held out" }),
314
+ /* @__PURE__ */ jsx("p", { className: "ophy-muted", children: "Each row answers a different question, so these are never averaged." }),
315
+ /* @__PURE__ */ jsxs("table", { className: "ophy-table", children: [
316
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
317
+ /* @__PURE__ */ jsx("th", { children: "Held out" }),
318
+ /* @__PURE__ */ jsx("th", { children: "Tested" }),
319
+ /* @__PURE__ */ jsx("th", { children: "True group consistent" }),
320
+ /* @__PURE__ */ jsx("th", { children: "True group excluded" }),
321
+ /* @__PURE__ */ jsx("th", { children: "Not evaluable" })
322
+ ] }) }),
323
+ /* @__PURE__ */ jsx("tbody", { children: ladder.map((r) => /* @__PURE__ */ jsxs("tr", { children: [
324
+ /* @__PURE__ */ jsx("th", { scope: "row", children: SCHEME[r.scheme] }),
325
+ /* @__PURE__ */ jsx("td", { children: r.tested }),
326
+ /* @__PURE__ */ jsx("td", { children: r.groups.reduce((n, g) => n + g.includedTruth, 0) }),
327
+ /* @__PURE__ */ jsx("td", { children: r.groups.reduce((n, g) => n + g.excludedTruth, 0) }),
328
+ /* @__PURE__ */ jsx("td", { children: r.groups.reduce((n, g) => n + g.notEvaluable, 0) })
329
+ ] }, r.scheme)) })
330
+ ] })
331
+ ] }),
332
+ claims && /* @__PURE__ */ jsx(ClaimsView, { claims, ...panel ? { panel } : {} }),
333
+ /* @__PURE__ */ jsx("p", { className: "ophy-muted", children: "These are simple counts on a small reference panel, and they aren't calibrated probabilities." })
334
+ ] });
335
+ }
336
+ function EventLog({ events }) {
337
+ return /* @__PURE__ */ jsx("ol", { className: "ophy-log", "aria-label": "Run log", children: events.map((e, i) => /* @__PURE__ */ jsxs("li", { className: `ophy-log-${e.level}`, children: [
338
+ /* @__PURE__ */ jsx("time", { children: e.at.slice(11, 19) }),
339
+ " ",
340
+ /* @__PURE__ */ jsx("span", { className: "ophy-log-step", children: e.step }),
341
+ " ",
342
+ e.message
343
+ ] }, i)) });
344
+ }
345
+ function parseNewickForDisplay(text) {
346
+ let i = 0;
347
+ const s = text.trim().replace(/;$/, "");
348
+ const node = () => {
349
+ const children = [];
350
+ if (s[i] === "(") {
351
+ i++;
352
+ children.push(node());
353
+ while (s[i] === ",") {
354
+ i++;
355
+ children.push(node());
356
+ }
357
+ if (s[i] !== ")") throw new Error("Unbalanced Newick text");
358
+ i++;
359
+ }
360
+ let label = "";
361
+ while (i < s.length && !",():".includes(s[i])) label += s[i++];
362
+ let length = 0;
363
+ if (s[i] === ":") {
364
+ i++;
365
+ let num = "";
366
+ while (i < s.length && !",()".includes(s[i])) num += s[i++];
367
+ length = Number(num) || 0;
368
+ }
369
+ return { label, length, children };
370
+ };
371
+ return node();
372
+ }
373
+ function TreeFigure({ newick, labelFor, thresholds }) {
374
+ const root = parseNewickForDisplay(newick);
375
+ const tips = [];
376
+ let maxX = 0;
377
+ const pos = /* @__PURE__ */ new Map();
378
+ const walk = (n, x) => {
379
+ maxX = Math.max(maxX, x);
380
+ let y;
381
+ if (!n.children.length) {
382
+ y = tips.length;
383
+ tips.push(n);
384
+ } else {
385
+ const ys = n.children.map((c) => walk(c, x + c.length));
386
+ y = (ys[0] + ys[ys.length - 1]) / 2;
387
+ }
388
+ pos.set(n, { x, y });
389
+ return y;
390
+ };
391
+ walk(root, 0);
392
+ const rowH = 18;
393
+ const treeW = 460;
394
+ const labelW = 300;
395
+ const px = (x) => 12 + (maxX ? x / maxX : 0) * treeW;
396
+ const py = (y) => 14 + y * rowH;
397
+ const lines = [];
398
+ const labels = [];
399
+ const pass = (label) => {
400
+ if (!thresholds?.length) return false;
401
+ const parts = label.split("/").map(Number);
402
+ return parts.length === thresholds.length && parts.every((v, k) => v >= thresholds[k]);
403
+ };
404
+ for (const [n, p] of pos) {
405
+ for (const c of n.children) {
406
+ const q = pos.get(c);
407
+ lines.push({ key: `${p.x}-${q.y}-${q.x}`, d: `M${px(p.x)} ${py(p.y)}V${py(q.y)}H${px(q.x)}` });
408
+ }
409
+ if (n.children.length && n.label) labels.push({ key: `s${p.x}${p.y}`, x: px(p.x) + 3, y: py(p.y) - 3, text: n.label, passes: pass(n.label) });
410
+ }
411
+ return /* @__PURE__ */ jsxs("svg", { className: "ophy-tree", viewBox: `0 0 ${treeW + labelW + 24} ${tips.length * rowH + 24}`, role: "img", "aria-label": `Tree with ${tips.length} tips`, children: [
412
+ lines.map((l) => /* @__PURE__ */ jsx("path", { d: l.d, className: "ophy-tree-edge" }, l.key)),
413
+ labels.map((l) => /* @__PURE__ */ jsx("text", { x: l.x, y: l.y, className: l.passes ? "ophy-tree-support ophy-tree-supported" : "ophy-tree-support", children: l.text }, l.key)),
414
+ tips.map((t) => {
415
+ const p = pos.get(t);
416
+ return /* @__PURE__ */ jsx("text", { x: px(p.x) + 6, y: py(p.y) + 4, className: "ophy-tree-tip", children: labelFor?.(t.label) ?? t.label }, t.label);
417
+ })
418
+ ] });
419
+ }
420
+ function OutgroupLadderView({ steps, suggested, caveat }) {
421
+ return /* @__PURE__ */ jsxs("section", { children: [
422
+ /* @__PURE__ */ jsx("ol", { className: "ophy-ladder", children: steps.map((s) => /* @__PURE__ */ jsxs("li", { className: s.taxon.name === suggested ? "ophy-ladder-suggested" : "", children: [
423
+ /* @__PURE__ */ jsx("strong", { children: s.taxon.name }),
424
+ " ",
425
+ /* @__PURE__ */ jsxs("span", { className: "ophy-muted", children: [
426
+ "(",
427
+ s.taxon.rank || "no rank",
428
+ ")"
429
+ ] }),
430
+ ": ",
431
+ s.records,
432
+ " records, ",
433
+ s.species.length,
434
+ " species",
435
+ s.sufficient ? "" : ". Not enough, so looking further out"
436
+ ] }, s.taxon.name)) }),
437
+ /* @__PURE__ */ jsx("p", { className: "ophy-muted", children: caveat })
438
+ ] });
439
+ }
440
+
441
+ export {
442
+ statusLabel,
443
+ StatusBadge,
444
+ summarizeResult,
445
+ CoverageBars,
446
+ PairTable,
447
+ ResultView,
448
+ LineageCard,
449
+ PanelSummary,
450
+ ClaimsView,
451
+ ValidationView,
452
+ EventLog,
453
+ parseNewickForDisplay,
454
+ TreeFigure,
455
+ OutgroupLadderView
456
+ };
@@ -0,0 +1,89 @@
1
+ // src/icons.tsx
2
+ import { jsx } from "react/jsx-runtime";
3
+ var ICON_PATHS = {
4
+ /** lucide: plus */
5
+ plus: "M5 12h14M12 5v14",
6
+ /** lucide: minus */
7
+ minus: "M5 12h14",
8
+ /** lucide: zoom-in */
9
+ zoomIn: "M3 11a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M21 21L16.65 16.65M11 8L11 14M8 11L14 11",
10
+ /** lucide: zoom-out */
11
+ zoomOut: "M3 11a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M21 21L16.65 16.65M8 11L14 11",
12
+ /** lucide: scan */
13
+ fit: "M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2",
14
+ /** lucide: download */
15
+ download: "M12 15V3M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4m7 10 5 5 5-5",
16
+ /** lucide: printer */
17
+ print: "M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6M7 14h10a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1Z",
18
+ /** lucide: undo-2 */
19
+ undo: "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11",
20
+ /** lucide: redo-2 */
21
+ redo: "m15 14 5-5-5-5M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13",
22
+ /** lucide: chevrons-up-down */
23
+ expand: "m7 15 5 5 5-5m7 9 5-5 5 5",
24
+ /** lucide: chevrons-down-up */
25
+ collapse: "m7 20 5-5 5 5m7 4 5 5 5-5",
26
+ /** lucide: arrow-down-up */
27
+ flip: "m3 16 4 4 4-4M7 20V4m21 8-4-4-4 4M17 4v16",
28
+ /** lucide: focus */
29
+ focus: "M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2",
30
+ /** lucide: expand */
31
+ unfocus: "m15 15 6 6m15 9 6-6M21 16v5h-5M21 8V3h-5M3 16v5h5m3 21 6-6M3 8V3h5M9 9 3 3",
32
+ /** lucide: network */
33
+ layout: "M17 16h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM3 16h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM10 2h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3M12 12V8",
34
+ /** lucide: funnel */
35
+ filter: "M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z",
36
+ /** lucide: palette */
37
+ palette: "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8zM13 6.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M17 10.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M6 12.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M8 7.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0",
38
+ /** lucide: x */
39
+ close: "M18 6 6 18m6 6 12 12",
40
+ /** lucide: external-link */
41
+ open: "M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",
42
+ /** lucide: grip-vertical */
43
+ grip: "M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
44
+ /** lucide: info */
45
+ info: "M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0M12 16v-4M12 8h.01",
46
+ /** lucide: check */
47
+ check: "M20 6 9 17l-5-5",
48
+ /** lucide: percent */
49
+ support: "M19 5L5 19M4 6.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0M15 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0",
50
+ /** lucide: bone */
51
+ fossil: "M17 10c.7-.7 1.69 0 2.5 0a2.5 2.5 0 1 0 0-5 .5.5 0 0 1-.5-.5 2.5 2.5 0 1 0-5 0c0 .81.7 1.8 0 2.5l-7 7c-.7.7-1.69 0-2.5 0a2.5 2.5 0 0 0 0 5c.28 0 .5.22.5.5a2.5 2.5 0 1 0 5 0c0-.81-.7-1.8 0-2.5Z",
52
+ /** lucide: image */
53
+ image: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM7 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21",
54
+ /** lucide: file-json */
55
+ file: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2zM14 2v5a1 1 0 0 0 1 1h5M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1",
56
+ /** lucide: book-open */
57
+ book: "M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",
58
+ /** lucide: chevron-left */
59
+ previous: "m15 18-6-6 6-6",
60
+ /** lucide: chevron-right */
61
+ next: "m9 18 6-6-6-6",
62
+ /** lucide: dna */
63
+ dna: "m10 16 1.5 1.5m14 8-1.5-1.5M15 2c-1.798 1.998-2.518 3.995-2.807 5.993m16.5 10.5 1 1m17 6-2.891-2.891M2 15c6.667-6 13.333 0 20-6m20 9 .891.891M3.109 14.109 4 15m6.5 12.5 1 1m7 18 2.891 2.891M9 22c1.798-1.998 2.518-3.995 2.807-5.993"
64
+ };
65
+ function Icon({ name, size = 16, className, ...rest }) {
66
+ return /* @__PURE__ */ jsx(
67
+ "svg",
68
+ {
69
+ viewBox: "0 0 24 24",
70
+ width: size,
71
+ height: size,
72
+ fill: "none",
73
+ stroke: "currentColor",
74
+ strokeWidth: 1.75,
75
+ strokeLinecap: "round",
76
+ strokeLinejoin: "round",
77
+ "aria-hidden": "true",
78
+ focusable: "false",
79
+ className: ["ophy-icon", className].filter(Boolean).join(" "),
80
+ ...rest,
81
+ children: /* @__PURE__ */ jsx("path", { d: ICON_PATHS[name] })
82
+ }
83
+ );
84
+ }
85
+
86
+ export {
87
+ ICON_PATHS,
88
+ Icon
89
+ };
@@ -0,0 +1,80 @@
1
+ import * as react from 'react';
2
+ import { SVGProps } from 'react';
3
+
4
+ /**
5
+ * The OneOrigin suite's line icons (brand/README.md), the same in OnePedigree, OneTaxonomy and
6
+ * OnePhylogeny: drawings from Lucide (https://lucide.dev, ISC license, © Lucide
7
+ * Contributors), the set Captive Origins already uses, flattened to one path each.
8
+ * 24 × 24, 1.75 stroke, round caps and joins, currentColor. Decorative by default
9
+ * (aria-hidden): put the words in the button's text, or in aria-label plus a
10
+ * visible tooltip (`data-tip`) when the icon stands alone.
11
+ */
12
+ declare const ICON_PATHS: {
13
+ /** lucide: plus */
14
+ readonly plus: "M5 12h14M12 5v14";
15
+ /** lucide: minus */
16
+ readonly minus: "M5 12h14";
17
+ /** lucide: zoom-in */
18
+ readonly zoomIn: "M3 11a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M21 21L16.65 16.65M11 8L11 14M8 11L14 11";
19
+ /** lucide: zoom-out */
20
+ readonly zoomOut: "M3 11a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M21 21L16.65 16.65M8 11L14 11";
21
+ /** lucide: scan */
22
+ readonly fit: "M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2";
23
+ /** lucide: download */
24
+ readonly download: "M12 15V3M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4m7 10 5 5 5-5";
25
+ /** lucide: printer */
26
+ readonly print: "M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6M7 14h10a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1Z";
27
+ /** lucide: undo-2 */
28
+ readonly undo: "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11";
29
+ /** lucide: redo-2 */
30
+ readonly redo: "m15 14 5-5-5-5M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13";
31
+ /** lucide: chevrons-up-down */
32
+ readonly expand: "m7 15 5 5 5-5m7 9 5-5 5 5";
33
+ /** lucide: chevrons-down-up */
34
+ readonly collapse: "m7 20 5-5 5 5m7 4 5 5 5-5";
35
+ /** lucide: arrow-down-up */
36
+ readonly flip: "m3 16 4 4 4-4M7 20V4m21 8-4-4-4 4M17 4v16";
37
+ /** lucide: focus */
38
+ readonly focus: "M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2";
39
+ /** lucide: expand */
40
+ readonly unfocus: "m15 15 6 6m15 9 6-6M21 16v5h-5M21 8V3h-5M3 16v5h5m3 21 6-6M3 8V3h5M9 9 3 3";
41
+ /** lucide: network */
42
+ readonly layout: "M17 16h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM3 16h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM10 2h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1ZM5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3M12 12V8";
43
+ /** lucide: funnel */
44
+ readonly filter: "M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z";
45
+ /** lucide: palette */
46
+ readonly palette: "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8zM13 6.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M17 10.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M6 12.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0M8 7.5a0.5 0.5 0 1 0 1 0a0.5 0.5 0 1 0 -1 0";
47
+ /** lucide: x */
48
+ readonly close: "M18 6 6 18m6 6 12 12";
49
+ /** lucide: external-link */
50
+ readonly open: "M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6";
51
+ /** lucide: grip-vertical */
52
+ readonly grip: "M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0";
53
+ /** lucide: info */
54
+ readonly info: "M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0M12 16v-4M12 8h.01";
55
+ /** lucide: check */
56
+ readonly check: "M20 6 9 17l-5-5";
57
+ /** lucide: percent */
58
+ readonly support: "M19 5L5 19M4 6.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0M15 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0";
59
+ /** lucide: bone */
60
+ readonly fossil: "M17 10c.7-.7 1.69 0 2.5 0a2.5 2.5 0 1 0 0-5 .5.5 0 0 1-.5-.5 2.5 2.5 0 1 0-5 0c0 .81.7 1.8 0 2.5l-7 7c-.7.7-1.69 0-2.5 0a2.5 2.5 0 0 0 0 5c.28 0 .5.22.5.5a2.5 2.5 0 1 0 5 0c0-.81-.7-1.8 0-2.5Z";
61
+ /** lucide: image */
62
+ readonly image: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM7 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21";
63
+ /** lucide: file-json */
64
+ readonly file: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2zM14 2v5a1 1 0 0 0 1 1h5M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1";
65
+ /** lucide: book-open */
66
+ readonly book: "M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z";
67
+ /** lucide: chevron-left */
68
+ readonly previous: "m15 18-6-6 6-6";
69
+ /** lucide: chevron-right */
70
+ readonly next: "m9 18 6-6-6-6";
71
+ /** lucide: dna */
72
+ readonly dna: "m10 16 1.5 1.5m14 8-1.5-1.5M15 2c-1.798 1.998-2.518 3.995-2.807 5.993m16.5 10.5 1 1m17 6-2.891-2.891M2 15c6.667-6 13.333 0 20-6m20 9 .891.891M3.109 14.109 4 15m6.5 12.5 1 1m7 18 2.891 2.891M9 22c1.798-1.998 2.518-3.995 2.807-5.993";
73
+ };
74
+ type IconName = keyof typeof ICON_PATHS;
75
+ declare function Icon({ name, size, className, ...rest }: {
76
+ name: IconName;
77
+ size?: number;
78
+ } & Omit<SVGProps<SVGSVGElement>, 'name'>): react.JSX.Element;
79
+
80
+ export { ICON_PATHS, Icon, type IconName };
package/dist/icons.js ADDED
@@ -0,0 +1,8 @@
1
+ import {
2
+ ICON_PATHS,
3
+ Icon
4
+ } from "./chunk-YY252G72.js";
5
+ export {
6
+ ICON_PATHS,
7
+ Icon
8
+ };
@@ -0,0 +1,86 @@
1
+ import * as react from 'react';
2
+ import { ClaimReport, Panel, RunEvent, FindingStatus, Result, ValidationReport } from '@captiveorigins/onephylogeny-core';
3
+
4
+ type Labels = Partial<Record<FindingStatus, string>>;
5
+ declare const statusLabel: (status: FindingStatus, labels?: Labels) => string;
6
+ /** A status always has words and a symbol, never color alone. */
7
+ declare function StatusBadge({ status, labels }: {
8
+ status: FindingStatus;
9
+ labels?: Labels;
10
+ }): react.JSX.Element;
11
+ /** The headline in plain words, including when the honest answer is that nothing can be said. */
12
+ declare function summarizeResult(result: Result, panel: Panel): string;
13
+ /** Coverage across the reference: why a group can or can't be compared with this sample. */
14
+ declare function CoverageBars({ panel, queryAligned, queryLabel }: {
15
+ panel: Panel;
16
+ queryAligned?: string;
17
+ queryLabel?: string;
18
+ }): react.JSX.Element;
19
+ /** Whether this sequence separates each pair of groups. "Not resolved" is a real answer. */
20
+ declare function PairTable({ result, panel }: {
21
+ result: Result;
22
+ panel: Panel;
23
+ }): react.JSX.Element | null;
24
+ /** Everything behind a conclusion: each group, its status, and why. */
25
+ declare function ResultView({ result, panel, queryAligned, labels }: {
26
+ result: Result;
27
+ panel: Panel;
28
+ queryAligned?: string;
29
+ labels?: Labels;
30
+ }): react.JSX.Element;
31
+ /** The calm version for a record page: one conclusion and a link to the detail. */
32
+ declare function LineageCard({ result, panel, marker, title, note, href }: {
33
+ result: Result;
34
+ panel: Panel;
35
+ marker: string;
36
+ title?: string;
37
+ note?: string;
38
+ href?: string;
39
+ }): react.JSX.Element;
40
+ declare function PanelSummary({ panel }: {
41
+ panel: Panel;
42
+ }): react.JSX.Element;
43
+ /** A claim is one pair of groups in one window. Every gate shows, so a failed claim says what stopped it. */
44
+ declare function ClaimsView({ claims, panel }: {
45
+ claims: ClaimReport[];
46
+ panel?: Panel;
47
+ }): react.JSX.Element;
48
+ declare function ValidationView({ blocks, groups, ladder, claims, panel }: {
49
+ blocks: ValidationReport;
50
+ groups: ValidationReport;
51
+ ladder?: ValidationReport[];
52
+ claims?: ClaimReport[];
53
+ panel?: Panel;
54
+ }): react.JSX.Element;
55
+ declare function EventLog({ events }: {
56
+ events: RunEvent[];
57
+ }): react.JSX.Element;
58
+ type TreeNode = {
59
+ label: string;
60
+ length: number;
61
+ children: TreeNode[];
62
+ };
63
+ /** Reads Newick for display only. Science lives elsewhere: a tree exported to OneTaxonomy uses its parser and checks. */
64
+ declare function parseNewickForDisplay(text: string): TreeNode;
65
+ /** A rectangular tree. Support labels stay as the source wrote them, such as 95.1/96. */
66
+ declare function TreeFigure({ newick, labelFor, thresholds }: {
67
+ newick: string;
68
+ labelFor?: (tip: string) => string;
69
+ thresholds?: number[];
70
+ }): react.JSX.Element;
71
+ type LadderStep = {
72
+ taxon: {
73
+ name: string;
74
+ rank: string;
75
+ };
76
+ records: number;
77
+ species: string[];
78
+ sufficient: boolean;
79
+ };
80
+ declare function OutgroupLadderView({ steps, suggested, caveat }: {
81
+ steps: LadderStep[];
82
+ suggested?: string;
83
+ caveat: string;
84
+ }): react.JSX.Element;
85
+
86
+ export { ClaimsView, CoverageBars, EventLog, type Labels, type LadderStep, LineageCard, OutgroupLadderView, PairTable, PanelSummary, ResultView, StatusBadge, TreeFigure, type TreeNode, ValidationView, parseNewickForDisplay, statusLabel, summarizeResult };
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ import {
2
+ ClaimsView,
3
+ CoverageBars,
4
+ EventLog,
5
+ LineageCard,
6
+ OutgroupLadderView,
7
+ PairTable,
8
+ PanelSummary,
9
+ ResultView,
10
+ StatusBadge,
11
+ TreeFigure,
12
+ ValidationView,
13
+ parseNewickForDisplay,
14
+ statusLabel,
15
+ summarizeResult
16
+ } from "./chunk-77BOVPO3.js";
17
+ export {
18
+ ClaimsView,
19
+ CoverageBars,
20
+ EventLog,
21
+ LineageCard,
22
+ OutgroupLadderView,
23
+ PairTable,
24
+ PanelSummary,
25
+ ResultView,
26
+ StatusBadge,
27
+ TreeFigure,
28
+ ValidationView,
29
+ parseNewickForDisplay,
30
+ statusLabel,
31
+ summarizeResult
32
+ };
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@captiveorigins/onephylogeny-react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "OnePhylogeny for React: server-renderable views of sample results, panel coverage, validation, run logs, outgroup suggestions and a compact lineage card.",
5
+ "keywords": [
6
+ "phylogenetics",
7
+ "react",
8
+ "reference-panel"
9
+ ],
10
+ "homepage": "https://github.com/CaptiveOrigins/onephylogeny#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/CaptiveOrigins/onephylogeny/issues"
13
+ },
14
+ "license": "PolyForm-Noncommercial-1.0.0",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/CaptiveOrigins/onephylogeny.git",
18
+ "directory": "packages/react"
19
+ },
20
+ "type": "module",
21
+ "sideEffects": [
22
+ "*.css"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./canvas": {
30
+ "types": "./dist/canvas.d.ts",
31
+ "import": "./dist/canvas.js"
32
+ },
33
+ "./icons": {
34
+ "types": "./dist/icons.d.ts",
35
+ "import": "./dist/icons.js"
36
+ },
37
+ "./styles.css": "./src/styles.css"
38
+ },
39
+ "main": "./dist/index.js",
40
+ "types": "./dist/index.d.ts",
41
+ "files": [
42
+ "dist",
43
+ "src/styles.css",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "engines": {
48
+ "node": ">=22"
49
+ },
50
+ "dependencies": {
51
+ "@captiveorigins/onephylogeny-core": "^0.1.0-alpha.1"
52
+ },
53
+ "peerDependencies": {
54
+ "react": "^19"
55
+ },
56
+ "devDependencies": {
57
+ "@types/react": "^19",
58
+ "@types/react-dom": "^19",
59
+ "react": "^19",
60
+ "react-dom": "^19"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup src/index.tsx src/canvas.tsx src/icons.tsx --format esm --dts --clean --target es2022 --external react",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run"
66
+ }
6
67
  }
package/src/styles.css ADDED
@@ -0,0 +1,52 @@
1
+ .ophy-muted { color: var(--ophy-muted, #5b6577); font-size: 0.92em; }
2
+ .ophy-warning { color: var(--ophy-warning, #8a5a14); }
3
+ .ophy-table { border-collapse: collapse; width: 100%; font-size: 13px; margin: 8px 0; }
4
+ .ophy-table th, .ophy-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--ophy-border, #eaddd6); vertical-align: top; font-weight: 400; }
5
+ .ophy-table thead th { font-weight: 600; color: var(--ophy-muted, #5b6577); }
6
+ .ophy-table tbody th { font-weight: 600; }
7
+ .ophy-status { display: inline-block; white-space: nowrap; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 12px; }
8
+ .ophy-status-consistent { color: var(--ophy-good, #1d6b47); }
9
+ .ophy-status-excluded_within_panel { color: var(--ophy-muted, #5b6577); }
10
+ .ophy-status-not_evaluable { color: var(--ophy-muted, #5b6577); border-style: dashed; }
11
+ .ophy-status-inconclusive { color: var(--ophy-warning, #8a5a14); }
12
+ .ophy-coverage { width: 100%; max-width: 760px; display: block; margin: 8px 0; }
13
+ .ophy-coverage-label { font-size: 12px; fill: currentColor; }
14
+ .ophy-coverage-axis { font-size: 10px; fill: var(--ophy-muted, #5b6577); }
15
+ .ophy-coverage-track { fill: var(--ophy-border, #eaddd6); opacity: 0.5; }
16
+ .ophy-coverage-group { fill: var(--ophy-ink, #91645d); }
17
+ .ophy-coverage-query { fill: var(--ophy-accent, #cf8137); }
18
+ .ophy-caveats { margin: 4px 0; padding-inline-start: 18px; font-size: 13px; }
19
+ .ophy-card { border: 1px solid var(--ophy-border, #eaddd6); border-radius: 10px; padding: 12px 14px; max-width: 440px; }
20
+ .ophy-card-title { font-size: 12px; color: var(--ophy-muted, #5b6577); }
21
+ .ophy-card-value { font-size: 17px; font-weight: 600; margin: 2px 0 6px; }
22
+ .ophy-log { list-style: none; margin: 0; padding: 8px; font: 12px/1.5 ui-monospace, Menlo, monospace; max-height: 260px; overflow: auto; border: 1px solid var(--ophy-border, #eaddd6); border-radius: 8px; }
23
+ .ophy-log time, .ophy-log-step { color: var(--ophy-muted, #5b6577); }
24
+ .ophy-log-warning { color: var(--ophy-warning, #8a5a14); }
25
+ .ophy-log-error { color: var(--ophy-flag, #a23a3d); font-weight: 600; }
26
+ .ophy-tree { width: 100%; display: block; color: inherit; }
27
+ .ophy-tree-edge { fill: none; stroke: var(--ophy-ink, #91645d); stroke-width: 1.2; }
28
+ .ophy-tree-tip { font-size: 11px; fill: currentColor; }
29
+ .ophy-tree-support { font-size: 8.5px; fill: var(--ophy-muted, #5b6577); }
30
+ .ophy-tree-supported { fill: var(--ophy-good, #1d6b47); font-weight: 700; }
31
+ .ophy-ladder { padding-inline-start: 20px; margin: 6px 0; }
32
+ .ophy-ladder-suggested { font-weight: 600; }
33
+ /* Tree canvas and floating toolbar, as in OnePedigree and OneTaxonomy. */
34
+ .ophy-icon { flex: none; }
35
+ .ophy-canvas { position: relative; overflow: hidden; touch-action: none; cursor: grab; border: 1px solid var(--ophy-border, #eaddd6); border-radius: 10px; }
36
+ .ophy-canvas:active { cursor: grabbing; }
37
+ .ophy-canvas:focus-visible { outline: 2px solid var(--ophy-accent, #cf8137); outline-offset: 1px; }
38
+ .ophy-canvas-content { transform-origin: 0 0; will-change: transform; }
39
+ .ophy-floating { position: absolute; top: 8px; right: 8px; z-index: 1; display: flex; gap: 4px; }
40
+ .ophy-floating button { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; cursor: pointer; }
41
+ .ophy-floating [data-tip] { position: relative; }
42
+ .ophy-floating [data-tip]:is(:hover, :focus-visible)::after {
43
+ content: attr(data-tip); position: absolute; top: calc(100% + 6px); right: 0; z-index: 2; white-space: nowrap; pointer-events: none;
44
+ padding: 4px 8px; border-radius: 6px; background: var(--ophy-ink, #91645d); color: #fff; font-size: 12px; line-height: 1.3;
45
+ }
46
+ @media print {
47
+ html[data-ophy-printing] body * { visibility: hidden; }
48
+ html[data-ophy-printing] .ophy-canvas, html[data-ophy-printing] .ophy-canvas * { visibility: visible; }
49
+ html[data-ophy-printing] .ophy-canvas { position: absolute; inset: 0; height: auto !important; overflow: visible; border: 0; }
50
+ html[data-ophy-printing] .ophy-canvas-content { transform: none !important; }
51
+ .ophy-floating { display: none; }
52
+ }