spanmark 0.1.0__py3-none-any.whl
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.
- spanmark/__init__.py +17 -0
- spanmark/_autosave.py +237 -0
- spanmark/_model.py +290 -0
- spanmark/_session.py +896 -0
- spanmark/_source.py +179 -0
- spanmark/_storage.py +97 -0
- spanmark/_version.py +24 -0
- spanmark/_widget.py +41 -0
- spanmark/static/widget.css +308 -0
- spanmark/static/widget.js +851 -0
- spanmark-0.1.0.dist-info/METADATA +436 -0
- spanmark-0.1.0.dist-info/RECORD +14 -0
- spanmark-0.1.0.dist-info/WHEEL +4 -0
- spanmark-0.1.0.dist-info/licenses/LICENSE +21 -0
spanmark/_source.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Private document sources for in-memory and indexed JSONL inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from spanmark._model import Document, normalize_document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InMemoryDocumentSource:
|
|
15
|
+
"""A normalized in-memory document source that preserves original records."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, documents: Sequence[Mapping[str, Any]]) -> None:
|
|
18
|
+
records: list[dict[str, Any]] = []
|
|
19
|
+
for index, document in enumerate(documents):
|
|
20
|
+
if not isinstance(document, Mapping):
|
|
21
|
+
raise TypeError(f"Document at index {index} must be a mapping")
|
|
22
|
+
records.append(dict(document))
|
|
23
|
+
|
|
24
|
+
normalized = tuple(
|
|
25
|
+
normalize_document(record, index) for index, record in enumerate(records)
|
|
26
|
+
)
|
|
27
|
+
if not normalized:
|
|
28
|
+
raise ValueError("documents must contain at least one document")
|
|
29
|
+
|
|
30
|
+
ids = tuple(document.id for document in normalized)
|
|
31
|
+
if len(set(ids)) != len(ids):
|
|
32
|
+
raise ValueError("document ids must be unique")
|
|
33
|
+
|
|
34
|
+
self._records = tuple(records)
|
|
35
|
+
self._documents = normalized
|
|
36
|
+
self.ids = ids
|
|
37
|
+
self._index_by_id = {
|
|
38
|
+
document_id: index for index, document_id in enumerate(ids)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def __len__(self) -> int:
|
|
42
|
+
return len(self._documents)
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, index: int) -> Document:
|
|
45
|
+
return self._documents[index]
|
|
46
|
+
|
|
47
|
+
def record(self, index: int) -> Mapping[str, Any]:
|
|
48
|
+
"""Return the original input mapping for *index*."""
|
|
49
|
+
return self._records[index]
|
|
50
|
+
|
|
51
|
+
def index_of(self, document_id: str) -> int:
|
|
52
|
+
return self._index_by_id[document_id]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JsonlDocumentSource:
|
|
56
|
+
"""A JSONL source indexed by byte offset instead of retained document text."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, path: os.PathLike[str] | Path) -> None:
|
|
59
|
+
self.path = Path(path)
|
|
60
|
+
self._offsets: tuple[int, ...] = ()
|
|
61
|
+
self._signature = (0, 0)
|
|
62
|
+
self.ids: tuple[str, ...] = ()
|
|
63
|
+
self._index_by_id: dict[str, int] = {}
|
|
64
|
+
self.refresh()
|
|
65
|
+
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self._offsets)
|
|
68
|
+
|
|
69
|
+
def __getitem__(self, index: int) -> Document:
|
|
70
|
+
return normalize_document(self.record(index), index)
|
|
71
|
+
|
|
72
|
+
def record(self, index: int) -> Mapping[str, Any]:
|
|
73
|
+
"""Load one original JSONL record by byte offset."""
|
|
74
|
+
if index < 0:
|
|
75
|
+
index += len(self)
|
|
76
|
+
if not 0 <= index < len(self):
|
|
77
|
+
raise IndexError(index)
|
|
78
|
+
|
|
79
|
+
self.check_unchanged()
|
|
80
|
+
|
|
81
|
+
with self.path.open("rb") as file:
|
|
82
|
+
file.seek(self._offsets[index])
|
|
83
|
+
raw_line = file.readline()
|
|
84
|
+
|
|
85
|
+
raw = _decode_json_line(
|
|
86
|
+
raw_line,
|
|
87
|
+
path=self.path,
|
|
88
|
+
line_number=None,
|
|
89
|
+
)
|
|
90
|
+
document = normalize_document(raw, index)
|
|
91
|
+
|
|
92
|
+
expected_id = self.ids[index]
|
|
93
|
+
if document.id != expected_id:
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
f"JSONL source {self.path} changed while the session was open: "
|
|
96
|
+
f"expected document {expected_id!r}, found {document.id!r}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return raw
|
|
100
|
+
|
|
101
|
+
def index_of(self, document_id: str) -> int:
|
|
102
|
+
return self._index_by_id[document_id]
|
|
103
|
+
|
|
104
|
+
def check_unchanged(self) -> None:
|
|
105
|
+
"""Raise if the indexed JSONL file changed since the last refresh."""
|
|
106
|
+
if _file_signature(self.path) != self._signature:
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"JSONL source {self.path} changed while the session was open"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def refresh(self) -> None:
|
|
112
|
+
"""Rebuild byte offsets after spanmark atomically rewrites this file."""
|
|
113
|
+
offsets: list[int] = []
|
|
114
|
+
ids: list[str] = []
|
|
115
|
+
index_by_id: dict[str, int] = {}
|
|
116
|
+
|
|
117
|
+
with self.path.open("rb") as file:
|
|
118
|
+
line_number = 0
|
|
119
|
+
document_index = 0
|
|
120
|
+
while True:
|
|
121
|
+
offset = file.tell()
|
|
122
|
+
raw_line = file.readline()
|
|
123
|
+
if not raw_line:
|
|
124
|
+
break
|
|
125
|
+
line_number += 1
|
|
126
|
+
|
|
127
|
+
if not raw_line.strip():
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
raw = _decode_json_line(
|
|
131
|
+
raw_line,
|
|
132
|
+
path=self.path,
|
|
133
|
+
line_number=line_number,
|
|
134
|
+
)
|
|
135
|
+
document = normalize_document(raw, document_index)
|
|
136
|
+
|
|
137
|
+
if document.id in index_by_id:
|
|
138
|
+
raise ValueError(f"document ids must be unique: {document.id!r}")
|
|
139
|
+
|
|
140
|
+
index_by_id[document.id] = document_index
|
|
141
|
+
offsets.append(offset)
|
|
142
|
+
ids.append(document.id)
|
|
143
|
+
document_index += 1
|
|
144
|
+
|
|
145
|
+
if not offsets:
|
|
146
|
+
raise ValueError("documents must contain at least one document")
|
|
147
|
+
|
|
148
|
+
self._offsets = tuple(offsets)
|
|
149
|
+
self.ids = tuple(ids)
|
|
150
|
+
self._index_by_id = index_by_id
|
|
151
|
+
self._signature = _file_signature(self.path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _decode_json_line(
|
|
155
|
+
raw_line: bytes,
|
|
156
|
+
*,
|
|
157
|
+
path: Path,
|
|
158
|
+
line_number: int | None,
|
|
159
|
+
) -> Mapping[str, Any]:
|
|
160
|
+
location = f"{path} line {line_number}" if line_number is not None else str(path)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
text = raw_line.decode("utf-8")
|
|
164
|
+
except UnicodeDecodeError as exc:
|
|
165
|
+
raise ValueError(f"Invalid UTF-8 in {location}") from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
raw: object = json.loads(text)
|
|
169
|
+
except json.JSONDecodeError as exc:
|
|
170
|
+
raise ValueError(f"Invalid JSON in {location}") from exc
|
|
171
|
+
|
|
172
|
+
if isinstance(raw, Mapping):
|
|
173
|
+
return cast(Mapping[str, Any], raw)
|
|
174
|
+
raise TypeError(f"Each JSONL record in {location} must be an object")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _file_signature(path: Path) -> tuple[int, int]:
|
|
178
|
+
stat = path.stat()
|
|
179
|
+
return stat.st_size, stat.st_mtime_ns
|
spanmark/_storage.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Small, boring JSONL persistence helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class FileFingerprint:
|
|
17
|
+
"""Stable identity for one complete file snapshot."""
|
|
18
|
+
|
|
19
|
+
size: int
|
|
20
|
+
sha256: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fingerprint_file(path: Path) -> FileFingerprint:
|
|
24
|
+
"""Return a content fingerprint for *path*."""
|
|
25
|
+
digest = sha256()
|
|
26
|
+
size = 0
|
|
27
|
+
|
|
28
|
+
with path.open("rb") as file:
|
|
29
|
+
while chunk := file.read(1024 * 1024):
|
|
30
|
+
digest.update(chunk)
|
|
31
|
+
size += len(chunk)
|
|
32
|
+
|
|
33
|
+
return FileFingerprint(size=size, sha256=digest.hexdigest())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def atomic_write_jsonl(
|
|
37
|
+
path: Path,
|
|
38
|
+
records: Iterable[Mapping[str, Any]],
|
|
39
|
+
) -> Path:
|
|
40
|
+
"""Atomically replace *path* with the supplied complete JSONL dataset."""
|
|
41
|
+
result, _ = atomic_write_jsonl_with_fingerprint(path, records)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def atomic_write_jsonl_with_fingerprint(
|
|
46
|
+
path: Path,
|
|
47
|
+
records: Iterable[Mapping[str, Any]],
|
|
48
|
+
) -> tuple[Path, FileFingerprint]:
|
|
49
|
+
"""Atomically replace *path* and return the written file fingerprint."""
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
tmp_path: Path | None = None
|
|
52
|
+
digest = sha256()
|
|
53
|
+
size = 0
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with tempfile.NamedTemporaryFile(
|
|
57
|
+
"wb",
|
|
58
|
+
dir=path.parent,
|
|
59
|
+
prefix=f".{path.name}.",
|
|
60
|
+
suffix=".tmp",
|
|
61
|
+
delete=False,
|
|
62
|
+
) as file:
|
|
63
|
+
tmp_path = Path(file.name)
|
|
64
|
+
for record in records:
|
|
65
|
+
line = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
|
|
66
|
+
file.write(line)
|
|
67
|
+
digest.update(line)
|
|
68
|
+
size += len(line)
|
|
69
|
+
file.flush()
|
|
70
|
+
os.fsync(file.fileno())
|
|
71
|
+
|
|
72
|
+
os.replace(tmp_path, path)
|
|
73
|
+
tmp_path = None
|
|
74
|
+
_fsync_directory(path.parent)
|
|
75
|
+
finally:
|
|
76
|
+
if tmp_path is not None:
|
|
77
|
+
tmp_path.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
return path, FileFingerprint(size=size, sha256=digest.hexdigest())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fsync_directory(path: Path) -> None:
|
|
83
|
+
"""Best-effort directory fsync after an atomic replacement on POSIX."""
|
|
84
|
+
if os.name != "posix":
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
file_descriptor = os.open(path, os.O_RDONLY)
|
|
89
|
+
except OSError:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
os.fsync(file_descriptor)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
finally:
|
|
97
|
+
os.close(file_descriptor)
|
spanmark/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
spanmark/_widget.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The thin anywidget bridge between Python state and the browser UI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import anywidget
|
|
6
|
+
import traitlets
|
|
7
|
+
|
|
8
|
+
_STATIC = Path(__file__).parent / "static"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpanmarkWidget(anywidget.AnyWidget):
|
|
12
|
+
"""Private browser widget used by `AnnotationSession`."""
|
|
13
|
+
|
|
14
|
+
_esm = _STATIC / "widget.js"
|
|
15
|
+
_css = _STATIC / "widget.css"
|
|
16
|
+
|
|
17
|
+
text = traitlets.Unicode("").tag(sync=True)
|
|
18
|
+
labels = traitlets.List(traitlets.Unicode(), default_value=[]).tag(sync=True)
|
|
19
|
+
spans = traitlets.List(traitlets.Dict(), default_value=[]).tag(sync=True)
|
|
20
|
+
|
|
21
|
+
doc_id = traitlets.Unicode("").tag(sync=True)
|
|
22
|
+
index = traitlets.Int(0).tag(sync=True)
|
|
23
|
+
total = traitlets.Int(0).tag(sync=True)
|
|
24
|
+
|
|
25
|
+
answer = traitlets.Unicode("").tag(sync=True)
|
|
26
|
+
flagged = traitlets.Bool(False).tag(sync=True)
|
|
27
|
+
allow_overlaps = traitlets.Bool(False).tag(sync=True)
|
|
28
|
+
can_undo = traitlets.Bool(False).tag(sync=True)
|
|
29
|
+
complete = traitlets.Bool(False).tag(sync=True)
|
|
30
|
+
reviewing_flagged = traitlets.Bool(False).tag(sync=True)
|
|
31
|
+
|
|
32
|
+
decided_count = traitlets.Int(0).tag(sync=True)
|
|
33
|
+
accept_count = traitlets.Int(0).tag(sync=True)
|
|
34
|
+
reject_count = traitlets.Int(0).tag(sync=True)
|
|
35
|
+
ignore_count = traitlets.Int(0).tag(sync=True)
|
|
36
|
+
flagged_count = traitlets.Int(0).tag(sync=True)
|
|
37
|
+
|
|
38
|
+
trim_whitespace = traitlets.Bool(True).tag(sync=True)
|
|
39
|
+
|
|
40
|
+
event = traitlets.Dict().tag(sync=True)
|
|
41
|
+
status = traitlets.Unicode("").tag(sync=True)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
.so-root {
|
|
2
|
+
/* Theme contract: component rules below consume only these --so-* tokens. */
|
|
3
|
+
--so-background: #ffffff;
|
|
4
|
+
--so-surface: #ffffff;
|
|
5
|
+
--so-text: #202124;
|
|
6
|
+
--so-muted-text: #5f6368;
|
|
7
|
+
--so-border: #dadce0;
|
|
8
|
+
--so-border-strong: #bdc1c6;
|
|
9
|
+
--so-hover: #f1f3f4;
|
|
10
|
+
--so-brand: #3b73b9;
|
|
11
|
+
--so-code-font: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
12
|
+
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
15
|
+
max-width: 1000px;
|
|
16
|
+
border: 1px solid var(--so-border);
|
|
17
|
+
border-radius: 9px;
|
|
18
|
+
padding: 13px;
|
|
19
|
+
background: var(--so-background);
|
|
20
|
+
color: var(--so-text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.so-head {
|
|
24
|
+
display: flex;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
gap: 12px;
|
|
27
|
+
align-items: center;
|
|
28
|
+
flex-wrap: wrap;
|
|
29
|
+
margin-bottom: 10px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.so-doc {
|
|
33
|
+
font-weight: 650;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.so-progress {
|
|
37
|
+
color: var(--so-muted-text);
|
|
38
|
+
font-size: 0.9em;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.so-labelbar,
|
|
42
|
+
.so-actions {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 6px;
|
|
46
|
+
flex-wrap: wrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.so-labelbar {
|
|
50
|
+
margin-bottom: 10px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.so-actions {
|
|
54
|
+
margin-top: 12px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.so-root button {
|
|
58
|
+
border: 1px solid var(--so-border-strong);
|
|
59
|
+
border-radius: 6px;
|
|
60
|
+
padding: 6px 10px;
|
|
61
|
+
background: var(--so-surface);
|
|
62
|
+
color: var(--so-text);
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
font-size: 0.9em;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.so-root button:hover {
|
|
68
|
+
background: var(--so-hover);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.so-root button:disabled {
|
|
72
|
+
cursor: default;
|
|
73
|
+
opacity: 0.45;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.so-root button:focus-visible {
|
|
77
|
+
outline: 2px solid var(--so-brand);
|
|
78
|
+
outline-offset: 2px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.so-labelbar button.so-label-button {
|
|
82
|
+
border-color: var(--so-label-color, var(--so-border-strong));
|
|
83
|
+
background: color-mix(in srgb,
|
|
84
|
+
var(--so-label-color, var(--so-brand)) 10%,
|
|
85
|
+
var(--so-surface));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.so-labelbar button.so-label-button.active {
|
|
89
|
+
background: var(--so-label-color, var(--so-brand));
|
|
90
|
+
color: var(--so-label-foreground, white);
|
|
91
|
+
border-color: var(--so-label-color, var(--so-brand));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.so-actions button {
|
|
95
|
+
min-width: 92px;
|
|
96
|
+
font-weight: 700;
|
|
97
|
+
padding: 8px 13px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.so-actions button[data-action="accept"].active {
|
|
101
|
+
background: #287a43;
|
|
102
|
+
color: white;
|
|
103
|
+
border-color: #287a43;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.so-actions button[data-action="reject"].active {
|
|
107
|
+
background: #a53b36;
|
|
108
|
+
color: white;
|
|
109
|
+
border-color: #a53b36;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.so-actions button[data-action="ignore"].active {
|
|
113
|
+
background: var(--so-muted-text);
|
|
114
|
+
color: var(--so-surface);
|
|
115
|
+
border-color: var(--so-muted-text);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.so-actions button[data-action="flag"].active {
|
|
119
|
+
background: #9a6b00;
|
|
120
|
+
color: white;
|
|
121
|
+
border-color: #9a6b00;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.so-text {
|
|
125
|
+
position: relative;
|
|
126
|
+
height: 400px;
|
|
127
|
+
box-sizing: border-box;
|
|
128
|
+
overflow-y: auto;
|
|
129
|
+
overflow-x: hidden;
|
|
130
|
+
white-space: pre-wrap;
|
|
131
|
+
overflow-wrap: anywhere;
|
|
132
|
+
line-height: calc(1.6em + var(--so-rail-extra, 0px));
|
|
133
|
+
padding: 16px;
|
|
134
|
+
border: 1px solid var(--so-border);
|
|
135
|
+
border-radius: 7px;
|
|
136
|
+
background: var(--so-surface);
|
|
137
|
+
color: var(--so-text);
|
|
138
|
+
cursor: text;
|
|
139
|
+
user-select: text;
|
|
140
|
+
--so-rail-extra: 0px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.so-textlayer {
|
|
144
|
+
position: relative;
|
|
145
|
+
z-index: 1;
|
|
146
|
+
white-space: pre-wrap;
|
|
147
|
+
overflow-wrap: anywhere;
|
|
148
|
+
user-select: text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.so-rail-layer {
|
|
152
|
+
position: absolute;
|
|
153
|
+
inset: 0;
|
|
154
|
+
z-index: 2;
|
|
155
|
+
pointer-events: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Classic non-overlap mode: inline labels participate in normal text layout. */
|
|
159
|
+
.so-textlayer mark.so-inline-span {
|
|
160
|
+
padding: 0 3px;
|
|
161
|
+
border-radius: 3px;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
background: color-mix(in srgb,
|
|
164
|
+
var(--so-span-color, var(--so-brand)) 22%,
|
|
165
|
+
transparent);
|
|
166
|
+
color: inherit;
|
|
167
|
+
border: 1px solid color-mix(in srgb,
|
|
168
|
+
var(--so-span-color, var(--so-brand)) 42%,
|
|
169
|
+
transparent);
|
|
170
|
+
line-height: 1.3;
|
|
171
|
+
box-decoration-break: clone;
|
|
172
|
+
-webkit-box-decoration-break: clone;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.so-textlayer mark.so-inline-span.so-model {
|
|
176
|
+
border-style: dashed;
|
|
177
|
+
border-color: var(--so-span-color, var(--so-brand));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.so-textlayer mark.so-inline-span.so-selected {
|
|
181
|
+
outline: 2px solid var(--so-span-color, var(--so-brand));
|
|
182
|
+
outline-offset: 1px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.so-inline-label {
|
|
186
|
+
display: inline-block;
|
|
187
|
+
margin-left: 5px;
|
|
188
|
+
color: var(--so-muted-text);
|
|
189
|
+
font-size: 0.6em;
|
|
190
|
+
font-weight: 750;
|
|
191
|
+
line-height: 1;
|
|
192
|
+
vertical-align: baseline;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
user-select: none;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.so-rail-seg,
|
|
198
|
+
.so-rail-badge {
|
|
199
|
+
position: absolute;
|
|
200
|
+
pointer-events: auto;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.so-rail-seg {
|
|
204
|
+
height: 0;
|
|
205
|
+
border-top: 3px solid var(--so-span-color, var(--so-brand));
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.so-rail-seg.so-model {
|
|
210
|
+
border-top-style: dashed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.so-rail-badge {
|
|
214
|
+
font-size: 0.72em;
|
|
215
|
+
line-height: 1.1;
|
|
216
|
+
padding: 1px 5px;
|
|
217
|
+
border-radius: 999px;
|
|
218
|
+
border: 1px solid var(--so-span-color, var(--so-brand));
|
|
219
|
+
background: color-mix(in srgb,
|
|
220
|
+
var(--so-span-color, var(--so-brand)) 12%,
|
|
221
|
+
var(--so-surface));
|
|
222
|
+
color: var(--so-text);
|
|
223
|
+
white-space: nowrap;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
user-select: none;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.so-rail-badge.so-model {
|
|
229
|
+
border-style: dashed;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.so-rail-seg.so-selected {
|
|
233
|
+
border-top-width: 5px;
|
|
234
|
+
filter: saturate(1.2);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.so-rail-badge.so-selected {
|
|
238
|
+
box-shadow: 0 0 0 2px var(--so-span-color, var(--so-brand));
|
|
239
|
+
filter: saturate(1.2);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.so-completion {
|
|
243
|
+
display: none;
|
|
244
|
+
margin-top: 8px;
|
|
245
|
+
padding: 24px 18px;
|
|
246
|
+
border: 1px solid var(--so-border);
|
|
247
|
+
border-radius: 8px;
|
|
248
|
+
text-align: center;
|
|
249
|
+
background: var(--so-surface);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.so-completion.show {
|
|
253
|
+
display: block;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.so-completion-title {
|
|
257
|
+
font-size: 1.35em;
|
|
258
|
+
font-weight: 750;
|
|
259
|
+
margin-bottom: 6px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.so-completion-counts {
|
|
263
|
+
display: flex;
|
|
264
|
+
justify-content: center;
|
|
265
|
+
gap: 12px;
|
|
266
|
+
flex-wrap: wrap;
|
|
267
|
+
margin: 14px 0;
|
|
268
|
+
color: var(--so-muted-text);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.so-completion-count {
|
|
272
|
+
padding: 5px 8px;
|
|
273
|
+
border: 1px solid var(--so-border);
|
|
274
|
+
border-radius: 999px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.so-completion-actions {
|
|
278
|
+
display: flex;
|
|
279
|
+
justify-content: center;
|
|
280
|
+
gap: 8px;
|
|
281
|
+
flex-wrap: wrap;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.so-completion button {
|
|
285
|
+
font-weight: 700;
|
|
286
|
+
padding: 8px 13px;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.so-help,
|
|
290
|
+
.so-status {
|
|
291
|
+
margin-top: 8px;
|
|
292
|
+
color: var(--so-muted-text);
|
|
293
|
+
font-size: 0.82em;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.so-status {
|
|
297
|
+
min-height: 1.2em;
|
|
298
|
+
font-weight: 550;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.so-key {
|
|
302
|
+
font-family: var(--so-code-font);
|
|
303
|
+
border: 1px solid var(--so-border-strong);
|
|
304
|
+
border-bottom-width: 2px;
|
|
305
|
+
border-radius: 4px;
|
|
306
|
+
padding: 0 4px;
|
|
307
|
+
font-size: 0.92em;
|
|
308
|
+
}
|