dynamic-systems-testing-framework 1.0.1__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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,721 @@
1
+ """The shell's stylesheet on top of `reporting.theme.css_variables()`
2
+ (DESIGN.md "Typography", "Header", "Spacing, shape, elevation", "Status
3
+ badges"): the font token on the body and on Quasar's controls — the
4
+ roadmap left the tool on Quasar's stack until this shell applied it — the
5
+ off-white header with its hairline, the 32 px rows with their status tint,
6
+ the filled badge, the sentence beside a greyed button. Every colour is a
7
+ token; nothing here names a hex."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from ...reporting.theme import STATUS
12
+
13
+ _PILLS = "".join(
14
+ f".dstf-pill-{status} {{ background: var(--dstf-{status}-pill); "
15
+ f"color: var(--dstf-{status}-dark); border-color: var(--dstf-{status}-border); }}\n"
16
+ for status in ("pass", "fail", "sim-fail", "no-ref", "not-run", "warn", "queued")
17
+ )
18
+
19
+ #: A criterion's swatch (legend b) in the colour its pill's border uses:
20
+ #: filled while its drawing shows, and `.dstf-swatch-off` empties it.
21
+ _SWATCHES = "".join(
22
+ f".dstf-swatch-{status} {{ border-color: var(--dstf-{status}-border); "
23
+ f"background: var(--dstf-{status}-border); }}\n"
24
+ for status in ("pass", "fail", "warn", "queued")
25
+ )
26
+
27
+ _TINTS = "".join(
28
+ f".dstf-tint-{status} {{ background: var(--dstf-{status}-tint); }}\n"
29
+ for status in STATUS
30
+ )
31
+
32
+ #: What Quasar draws itself, under a theme (D148; `Shell.page_setup` lays it
33
+ #: after `STYLE`). Under `body--dark` — which `TabTheme` sets for a dark
34
+ #: theme — Quasar paints its own surfaces from `--q-dark` / `--q-dark-page`
35
+ #: (the palette's) and these are the pieces that name a colour of their own.
36
+ QUASAR = """
37
+ /* A filled primary button's text is the token's, not Quasar's white: on the
38
+ dark accent white reads 2.2:1. Quasar's `.text-white` is `!important`
39
+ INSIDE NiceGUI's `quasar_importants` layer, and a layered important beats
40
+ any unlayered one — the rule joins that layer and wins there on
41
+ specificity (unlayered, the button stayed rgb(255, 255, 255) in Chromium). */
42
+ @layer quasar_importants {
43
+ .q-btn.bg-primary { color: var(--dstf-on-accent) !important; }
44
+ /* A disabled item of a bar's ⋮ menu reads its reason under its name:
45
+ Quasar dims the whole item to 60 % — an `!important` of that layer —
46
+ which put the reason, already the muted text, at 2:1. The name is
47
+ dimmed below; the sentence is not. */
48
+ .dstf-menu-action.disabled { opacity: 1 !important; }
49
+ }
50
+ .dstf-menu-action.disabled .dstf-menu-name { opacity: 0.6; }
51
+ /* The window's own ground follows the tab's theme: `first_paint` gave it
52
+ the project's before this stylesheet arrived. */
53
+ html { background: var(--dstf-page); }
54
+ body.body--dark { background: var(--dstf-page); color: var(--dstf-text); }
55
+ .body--dark .q-card, .body--dark .q-menu, .body--dark .q-dialog__inner > div,
56
+ .body--dark .q-drawer { background: var(--dstf-surface); color: var(--dstf-text); }
57
+ .body--dark .q-tooltip { background: var(--dstf-surface-active); color: var(--dstf-text); }
58
+ .body--dark .q-field__native, .body--dark .q-field__input,
59
+ .body--dark .q-field__label, .body--dark .q-item__label--caption {
60
+ color: var(--dstf-text-secondary); }
61
+ .body--dark .q-separator { background: var(--dstf-border); }
62
+ """
63
+
64
+ STYLE = (
65
+ """
66
+ body, .q-header, .q-drawer, .q-btn, .q-field, .q-badge, .q-dialog, .q-tooltip, .q-item {
67
+ font-family: var(--dstf-font);
68
+ }
69
+ body { background: var(--dstf-page); color: var(--dstf-text); }
70
+ .dstf-header.q-header {
71
+ background: var(--dstf-page) !important; color: var(--dstf-text) !important;
72
+ border-bottom: 1px solid var(--dstf-border); box-shadow: none;
73
+ /* No padding of its own — NiceGUI's `.nicegui-header` brings 16 px on
74
+ every side — because each ROW carries the side padding instead: on the
75
+ header it inset the sub-header's border-top, which then stopped short
76
+ of both edges while the header's own border-bottom ran to them. */
77
+ padding: 0;
78
+ /* NiceGUI's own `.nicegui-header { flex-direction: row }` outranks
79
+ Quasar's `.column`: without this the Test page's second row renders to
80
+ the RIGHT of the first with Rerun / Accept clipped off-screen, and
81
+ every simulation test still passes. */
82
+ flex-direction: column; flex-wrap: nowrap; align-items: stretch;
83
+ }
84
+ .dstf-header-row { padding: 4px 24px; min-height: 40px; }
85
+ .dstf-subheader {
86
+ border-top: 1px solid var(--dstf-border); padding: 4px 24px; min-height: 40px;
87
+ }
88
+ /* The Test page's subheader WRAPS: kept to one line beside a real
89
+ library's model id, it put Accept and Rerun past a 900 px window (wholly
90
+ off it on a TRANSFORM-length id) and squeezed Rerun to seven lines. The
91
+ id breaks at any character when a line cannot hold it. The TEST actions
92
+ are the status strip's now, below it. */
93
+ .dstf-subheader .dstf-model { overflow-wrap: anywhere; }
94
+ /* The Test page's status strip: a third row of the header, its tone a
95
+ 3 px left border and a tint. The sentence takes what is left and wraps;
96
+ its buttons keep to the right as one group, below it when they do not
97
+ fit. */
98
+ .dstf-strip {
99
+ border-top: 1px solid var(--dstf-border); border-left: 3px solid;
100
+ padding: 4px 24px 4px 21px; min-height: 36px; row-gap: 4px;
101
+ }
102
+ .dstf-strip-grey { background: var(--dstf-queued-tint); border-left-color: var(--dstf-queued-border); }
103
+ .dstf-strip-red { background: var(--dstf-fail-tint); border-left-color: var(--dstf-fail-border); }
104
+ .dstf-strip-amber { background: var(--dstf-warn-tint); border-left-color: var(--dstf-warn-border); }
105
+ .dstf-strip-text { flex: 1 1 20em; }
106
+ .dstf-strip > .dstf-strip-actions { margin-left: auto; }
107
+ /* A pill is at least as wide as WARN, its text centred: PASS and FAIL are
108
+ one width, so the names after them on a plot's criteria rows start in
109
+ one column (D140; the report's `.node-status` keeps the same). */
110
+ .dstf-pill {
111
+ font-size: 10.5px; font-weight: 700; text-transform: uppercase;
112
+ letter-spacing: .03em; padding: 1px 6px; border-radius: 3px; border: 1px solid;
113
+ min-width: 4.8em; text-align: center;
114
+ }
115
+ /* A pill that carries its variable's name ("x FAIL", on a card drawing
116
+ more than one series): the name keeps the case the test wrote it in. */
117
+ .dstf-pill-named { text-transform: none; }
118
+ .dstf-tree-line, .dstf-stats-line { min-height: 32px; }
119
+ .dstf-tree-row {
120
+ min-height: 32px; border-top: 1px solid var(--dstf-border-hairline);
121
+ }
122
+ .dstf-tree-row:hover { background: var(--dstf-surface-hover); }
123
+ /* A row whose click opens something (U5): a criterion's editor, a k-of-n's
124
+ or a weighted's fields — a button to the mouse and the keyboard. */
125
+ .dstf-tree-row[role="button"] { cursor: pointer; }
126
+ .dstf-tree-row[role="button"]:focus-visible {
127
+ outline: 2px solid var(--dstf-accent); outline-offset: -2px;
128
+ }
129
+ /* Where a row sits (U5, Q1): a 6 px bar per level, 2 px apart, the row's
130
+ full height, in place of an indent; the hovered group's branch takes its
131
+ level's deeper shade on that level's bar (`.dstf-lvl-on`, set by the
132
+ page's script on the BAR, never the row). Then the chevron's place,
133
+ kept on every row so the pills stay in line. The bars cover the row's
134
+ top border too (the -1 px): stretched inside it they stopped 1 px short
135
+ of the row above's, a gap in every branch. */
136
+ .dstf-bars {
137
+ display: flex; flex: none; align-self: stretch; gap: 2px; margin-top: -1px;
138
+ }
139
+ .dstf-bar { width: 6px; flex: none; }
140
+ .dstf-lvl-1 { background: var(--dstf-level-1); }
141
+ .dstf-lvl-2 { background: var(--dstf-level-2); }
142
+ .dstf-lvl-3 { background: var(--dstf-level-3); }
143
+ .dstf-lvl-4 { background: var(--dstf-level-4); }
144
+ .dstf-lvl-1.dstf-lvl-on { background: var(--dstf-level-1-on); }
145
+ .dstf-lvl-2.dstf-lvl-on { background: var(--dstf-level-2-on); }
146
+ .dstf-lvl-3.dstf-lvl-on { background: var(--dstf-level-3-on); }
147
+ .dstf-lvl-4.dstf-lvl-on { background: var(--dstf-level-4-on); }
148
+ .dstf-chev-slot { width: 28px; flex: none; display: inline-flex; justify-content: center; }
149
+ /* A group's chevron (U5, Q3): a 28 px target, an 18 px glyph, turned
150
+ right while its row is folded; the row then says what it hides. */
151
+ .dstf-chev {
152
+ width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%;
153
+ background: transparent; color: var(--dstf-text-secondary); cursor: pointer;
154
+ display: inline-flex; align-items: center; justify-content: center;
155
+ }
156
+ .dstf-chev:hover { background: var(--dstf-surface-active); }
157
+ .dstf-chev:focus-visible { outline: 2px solid var(--dstf-accent); }
158
+ .dstf-chev .q-icon { font-size: 18px; transition: transform .12s; }
159
+ .dstf-tree-row[data-folded="1"] .dstf-chev .q-icon { transform: rotate(-90deg); }
160
+ .dstf-fold-summary { color: var(--dstf-text-secondary); font-size: 12px; }
161
+ /* A long row never widens the page: its text wraps — anywhere, as a
162
+ TRANSFORM name has no space to break at — and its readout keeps one
163
+ line, cut with an ellipsis, the whole of it in its title (`_Row.paint`).
164
+ At 900 px a deep criterion's name and readout pushed the page 48 px
165
+ sideways. The readout shrinks with the name down to 80 px and no
166
+ further: shrinking from 0, a 200-character name left it 71 px at 900
167
+ (J2); kept whole up to 40% of the row, it left the J2 tests' names
168
+ 119-143 px at 900, five lines and more, where now they have 290-350. */
169
+ .dstf-tree-row > .dstf-tree-text { min-width: 0; overflow-wrap: anywhere; }
170
+ .dstf-tree-row > .dstf-tree-readout {
171
+ flex: 0 1 auto; min-width: 80px;
172
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
173
+ }
174
+ .dstf-tree-row:not([data-folded="1"]) .dstf-fold-summary { display: none; }
175
+ /* The root's tag (Q2): "and · 5 criteria · root". */
176
+ .dstf-root-tag { color: var(--dstf-text-muted); font-size: 12px; }
177
+ .dstf-root-tag::before { content: "· "; }
178
+ /* The drag (U4): a handle before the pill, its place kept while hidden so
179
+ the pills stay in line (`.dstf-grip-off`: never Quasar's `.hidden`, whose
180
+ cascade layer beats an unlayered `!important`). Over a row: a line at the row's indent where the
181
+ node would go before it, an outline onto it, a red outline where the
182
+ move is refused — its reason then fills the card's header line, in place
183
+ of the line's text (`.dstf-line-why`): said in the row it reflowed the
184
+ row's icons. */
185
+ .dstf-tree-row { position: relative; }
186
+ .dstf-grip {
187
+ width: 20px; flex: none; display: inline-flex; justify-content: center;
188
+ color: var(--dstf-text-muted); cursor: grab;
189
+ }
190
+ .dstf-grip-off { visibility: hidden; }
191
+ .dstf-drop-before::before {
192
+ content: ""; position: absolute; top: -2px; right: 0;
193
+ left: var(--indent, 0px); height: 3px; background: var(--dstf-accent);
194
+ pointer-events: none;
195
+ }
196
+ .dstf-drop-onto { outline: 2px solid var(--dstf-accent); outline-offset: -2px; }
197
+ .dstf-drop-no {
198
+ outline: 2px solid var(--dstf-fail-border); outline-offset: -2px;
199
+ cursor: not-allowed;
200
+ }
201
+ .dstf-tree-line.dstf-line-why > * { display: none !important; }
202
+ .dstf-tree-line.dstf-line-why::before {
203
+ content: attr(data-why); color: var(--dstf-fail-dark);
204
+ }
205
+ /* After a move (V4): its row flashed once as it comes into view, and one
206
+ line under it saying what the move changed. */
207
+ @keyframes dstf-flash { from { background: var(--dstf-selection); } }
208
+ .dstf-flash { animation: dstf-flash 1.2s ease-out; }
209
+ /* Its scroll margin: what is above the tree settles a pixel lower after
210
+ the line is scrolled to (the page's header row and strip repaint). */
211
+ .dstf-moved { color: var(--dstf-text-secondary); scroll-margin-bottom: 8px; }
212
+ /* The tree card's live region (`TreeCard.live`): read, never seen. */
213
+ .dstf-live {
214
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
215
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
216
+ }
217
+ /* Move to… (U4): a place per line, its node's text cut in the middle,
218
+ then where in it — "— before" whole and the row it names cut in the
219
+ middle too; the menu no wider than the page allows. Kept whole, a
220
+ 100-character name ran past the menu's edge and squeezed the node's
221
+ text to 0 px. The named row takes what the node's text and "— before"
222
+ leave (a basis of 0), so it is cut first; the node's text is cut only
223
+ past that, to no less than 6ch — shrunk alike, a k-of-n's read only
224
+ its tail. "Where" lays its parts out in the label's own row
225
+ (`contents`): as a box of its own, its minimum width was its whole
226
+ text, cut or not. */
227
+ .dstf-move-menu { max-width: min(560px, 92vw); }
228
+ .dstf-move-label {
229
+ display: flex; gap: .4em; min-width: 0; white-space: nowrap;
230
+ }
231
+ .dstf-move-label > .dstf-mid { flex: 0 1 auto; min-width: 6ch; }
232
+ .dstf-move-where { display: contents; }
233
+ .dstf-move-where > * { flex: none; }
234
+ .dstf-move-where > .dstf-mid { flex: 1 1 0; min-width: 0; }
235
+ /* A combinator's kind dropdown, in its row's lead place: a dense Quasar
236
+ field is 40 px tall, which stood a combinator's row well above a
237
+ leaf's; at 28 px the rows read as one list. */
238
+ .dstf-kind .q-field__control, .dstf-kind .q-field__native,
239
+ .dstf-kind .q-field__marginal { min-height: 28px; height: 28px; }
240
+ /* A plot card: its title row, its plot, its criteria header and rows at
241
+ the card's own spacing (the brief's "col gap 10px"). */
242
+ .dstf-plot { gap: 10px; }
243
+ /* A card's header row wraps (3c's gate over a real library: at 900 px
244
+ beside a drawer, a long title, its pills and the toolbar in one row
245
+ that neither wrapped nor shrank scrolled the page sideways). The title
246
+ and its pills take what is left and wrap inside it, a long name cut in
247
+ its middle (`.dstf-mid`); the toolbar wraps as one unit, pushed to the
248
+ right. No
249
+ `no-wrap` on the row: kept to one line, the title shrank instead (to
250
+ three and four lines at 900 px beside a drawer). */
251
+ .dstf-card-head { row-gap: 4px; }
252
+ /* `min-width: 0`: a title cut in its middle is one unbreakable line, and
253
+ the row's automatic minimum — that whole line — pushed the page sideways
254
+ (1477 px on a 150-character title). */
255
+ .dstf-card-title { flex: 1 1 auto; min-width: 0; }
256
+ .dstf-card-tools { flex: none; margin-left: auto; }
257
+ .dstf-plot-slot { min-height: 320px; --toolbar-gutter: 34px; }
258
+ /* The plot's vertical toolbar stands in a gutter of its own, right of the
259
+ figure, instead of over the error axis's tick labels and title: a margin
260
+ reserved inside the figure could not know how wide those labels grow
261
+ (an error around 1e-5 outgrew every reserve measured). The plot is the
262
+ gutter's width narrower than its slot: `width: auto` undoes the plot's
263
+ `w-full`, so the margin narrows the plot instead of hanging the toolbar
264
+ 15 px past the card. Plotly pins the toolbar's container with an inline
265
+ `right: 0px`, hence the `!important`. The width is not a `--dstf-`
266
+ token: those are the theme's table (theme.py). */
267
+ .dstf-plot-slot > .js-plotly-plot {
268
+ width: auto; margin-right: var(--toolbar-gutter);
269
+ }
270
+ .dstf-plot-slot .js-plotly-plot .modebar-container {
271
+ right: calc(-1 * var(--toolbar-gutter)) !important;
272
+ }
273
+ /* A tolerance grip — the arrow outside each side of the open criterion's
274
+ tolerance — is hidden at rest: `grips.js` marks each grip's path with its
275
+ group (`data-grip`) and, of the cluster of groups the pointer is in,
276
+ every one (`data-grip-on`). `display: none`, never `visibility` or `opacity`:
277
+ Plotly writes inline `opacity: 1; pointer-events: all` on every editable
278
+ shape, so a grip hidden either way was still grabbed where nothing
279
+ showed. Without a hover — a touch screen — they are always shown.
280
+ But an arrow whose edge is OFF the plot (`data-grip-off`, marked by
281
+ `grips.js` on every redraw): that one is not displayed at all, its
282
+ group shown or not, hover or none. One rule for an edge that is not on
283
+ the plot — no arrow. Left Plotly's clip instead, an arrow was still
284
+ painted wherever the clip cut nothing: whole, inside the panel, when it
285
+ is drawn inward of an edge 2 px off it; a sliver on the border beside
286
+ a checkpoint just past it. And the hover label gave way next to it. */
287
+ .js-plotly-plot .shapelayer path[data-grip]:not([data-grip-on]) { display: none; }
288
+ .js-plotly-plot .shapelayer path[data-grip-off] { display: none; }
289
+ /* And a grip is not clipped to the plot area. Plotly clips every shape to
290
+ it, and an arrow stands 10 to 17 px OUTSIDE its edge: with that edge at
291
+ the plot's edge, where any zoom can put it, the arrow was all in the
292
+ margin — not painted, and a press where it belonged landed on the axis
293
+ under it. Unclipped it is drawn whole in the margin, over the tick
294
+ labels, and takes the press: the shape layer lies above the axes' drag
295
+ layer. Only the svg's own edge cuts it. Every other shape keeps its clip
296
+ — a ring too: centred on its point, its inner half is drawn and pressed
297
+ while the point is on the plot.
298
+ Plotly writes the clip as the `clip-path` ATTRIBUTE, in two places: on
299
+ a shape's GROUP as it is drawn — every redraw draws new groups — and,
300
+ from a press to its release, on the held PATH itself, for every axis
301
+ whose range is set (any zoom sets them). A property of a stylesheet
302
+ beats a presentation attribute wherever it is written and whenever, so
303
+ these two rules are the whole mechanism: nothing takes an attribute
304
+ off, and an arrow pressed in the margin of a zoomed plot stays painted
305
+ while it is held. A browser without `:has` (Firefox before 121) drops
306
+ the group's rule, and the path's alone un-clips nothing: there an
307
+ arrow at the plot's edge is clipped as Plotly draws it.
308
+ The un-clip is for the 10 to 17 px an arrow stands outside an edge that
309
+ is ON the plot. An arrow whose edge is off it — the arrows of a
310
+ checkpoint zoomed just off the plot were painted in the margin beside
311
+ the axis title, and took a press — is not displayed (above), so no
312
+ rule here needs to leave it out. */
313
+ .js-plotly-plot .shapelayer path[data-grip] { clip-path: none; }
314
+ .js-plotly-plot .shapelayer g.shape-group:has(> path[data-grip]) { clip-path: none; }
315
+ /* An arrow that would be drawn over another of its cluster is moved ACROSS
316
+ its own axis by whole steps of 9 px (`grips.js` `stagger`, which marks
317
+ it with the steps: `data-grip-shift`, -3, -1, 1 or 3): a time or a
318
+ frequency arrow up or down, a value arrow left or right. A `transform`
319
+ of the stylesheet, so the path Plotly drew and drags is untouched: held,
320
+ it follows the pointer from where it is painted, and a release says its
321
+ edge. The 9 is `grips.js`'s STEP — it works out which arrows clear which
322
+ at that step. */
323
+ .js-plotly-plot .shapelayer path[data-grip-shift="-3"] { --grip-across: -27px; }
324
+ .js-plotly-plot .shapelayer path[data-grip-shift="-1"] { --grip-across: -9px; }
325
+ .js-plotly-plot .shapelayer path[data-grip-shift="1"] { --grip-across: 9px; }
326
+ .js-plotly-plot .shapelayer path[data-grip-shift="3"] { --grip-across: 27px; }
327
+ .js-plotly-plot .shapelayer path[data-grip-axis="x"][data-grip-shift] {
328
+ transform: translate(0, var(--grip-across));
329
+ }
330
+ .js-plotly-plot .shapelayer path[data-grip-axis="y"][data-grip-shift] {
331
+ transform: translate(var(--grip-across), 0);
332
+ }
333
+ /* The cursor over an arrow is the way it moves — up and down for a value
334
+ side, left and right for a time or a frequency side — by the axis
335
+ `grips.js` marks it with. Plotly's own for a draggable shape is `move`,
336
+ set as the pointer comes by a CLASS on the path (`.js-plotly-plot .plotly
337
+ .cursor-move`, specificity 0,3,0), never inline: these rules — two
338
+ classes, an attribute and an element, 0,3,1 — outrank it as they
339
+ stand. */
340
+ .js-plotly-plot .shapelayer path[data-grip-axis="y"] { cursor: ns-resize; }
341
+ .js-plotly-plot .shapelayer path[data-grip-axis="x"] { cursor: ew-resize; }
342
+ /* While the pointer is near a shown arrow or the ring its group stands
343
+ around (`grips.js` marks the graph div), the plot's hover label is not
344
+ shown: coming at a checkpoint from above or below, the unified label's
345
+ box and its spike line covered the arrows and half the ring. It is back
346
+ as soon as the pointer is away from them — arrows shown or not. By
347
+ `visibility`: the layer keeps its layout, which Plotly measures to size
348
+ a label it draws meanwhile. */
349
+ .js-plotly-plot[data-grip-near] .hoverlayer { visibility: hidden; }
350
+ /* A shape no editor moves — a box, a window, a result line, another
351
+ criterion's bound — takes no pointer: `grips.js` marks it `data-inert`
352
+ from what the figure says is draggable. Plotly makes EVERY shape of an
353
+ editable figure draggable (inline `pointer-events: all`, hence the
354
+ `!important`), so such a shape showed a resize cursor, moved under the
355
+ mouse and snapped back on release with nothing said. A press on it is now
356
+ the plot's own. A line is dragged by a helper group of its own, marked
357
+ too — and that helper is not clipped to the plot area as the line is:
358
+ a bound line an editor DOES move is marked the same while it is off
359
+ the plot whole, where its helper alone was left to press. */
360
+ .js-plotly-plot .shapelayer [data-inert],
361
+ .js-plotly-plot .shapelayer [data-inert] * { pointer-events: none !important; }
362
+ @media (hover: none), (pointer: coarse) {
363
+ .js-plotly-plot .shapelayer path[data-grip]:not([data-grip-on]):not([data-grip-off]) {
364
+ display: inline;
365
+ }
366
+ }
367
+ .dstf-crit {
368
+ min-height: 36px; padding: 4px 8px; cursor: pointer;
369
+ border-top: 1px solid var(--dstf-border-hairline);
370
+ }
371
+ .dstf-crit:hover { background: var(--dstf-surface-hover); }
372
+ /* A row's name (metric · variable) is one line: beside an open drawer a
373
+ card is ~540 px at 900 px, and it broke inside itself ("tube" over
374
+ "· x"). The readout beside it may wrap. */
375
+ .dstf-crit-name { white-space: nowrap; }
376
+ /* A variable label cut in the MIDDLE (D140, Q20; `widgets.MiddleCut`): the
377
+ head ellipsized, the tail (`[3]`, the element) whole. Two inline-blocks
378
+ in an inline-block — never a flex pair — so innerText and a copy read the
379
+ whole label; the head is capped at the element's width less the tail's
380
+ (`--tail`, its length in ch), and the tail is at least that wide: in a
381
+ proportional font `[3]` is narrower than 3ch, and a head capped by more
382
+ than the tail took was cut with room to spare. In a flex row the element
383
+ shrinks: `overflow: hidden` makes its minimum width 0. */
384
+ .dstf-mid {
385
+ display: inline-block; max-width: 100%; overflow: hidden;
386
+ white-space: nowrap; vertical-align: bottom;
387
+ }
388
+ .dstf-mid-head {
389
+ display: inline-block; max-width: calc(100% - var(--tail, 0ch));
390
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
391
+ vertical-align: bottom;
392
+ }
393
+ .dstf-mid-tail {
394
+ display: inline-block; min-width: var(--tail, 0ch); white-space: nowrap;
395
+ vertical-align: bottom;
396
+ }
397
+ /* A row's breadcrumb — its ancestors' kinds, `and › k-of-n ›` — leads it,
398
+ muted and on one line: a plot's rows are its variable's leaves from
399
+ anywhere in the tree, and the crumb says where each sits. Its minimum
400
+ width is its card's longest crumb, in ch (`PlotCard`), so a plot's
401
+ `and ›` and `and › k-of-n ›` rows start their names in one column (the
402
+ report's `.node-crumbs` does the same). */
403
+ .dstf-crumb { white-space: nowrap; }
404
+ /* A plot's context series (grey on the plot, not scored), listed above it
405
+ each with the × that takes it off: Plotly's legend cannot hold a
406
+ button. */
407
+ .dstf-context { padding: 0 8px; }
408
+ .dstf-context-chip {
409
+ border: 1px solid var(--dstf-border-hairline); border-radius: 4px;
410
+ padding: 0 0 0 6px;
411
+ }
412
+ /* A long criterion name keeps its one line, so at narrow widths the row
413
+ wraps (it carries no `no-wrap`): the readout and the node's controls move
414
+ to a second line, the controls at its right end, instead of pushing the
415
+ page sideways (3c's gate over a real library, 900 px beside a drawer). */
416
+ .dstf-crit > .dstf-node-host { margin-left: auto; }
417
+ /* A plot's criteria header: a muted "Criteria" and, at its right end, what
418
+ attaches at VARIABLE (the plot's +), level with the criteria rows'
419
+ controls below it. */
420
+ .dstf-criteria-head { min-height: 32px; padding: 0 8px; }
421
+ .dstf-criteria-head > .dstf-node-host { margin-left: auto; }
422
+ /* It wraps (no `no-wrap`): under a lock the + gives way to "… is running
423
+ on <model>", longer than a card beside a drawer at 900 px. Its form
424
+ row's slot, below it, holds the host's hidden error labels even when no
425
+ form is open, so it is never `:empty` — it sits flush under the header
426
+ instead, and the card's gap is not paid twice. */
427
+ .dstf-criteria-head + .dstf-criteria-slot { margin-top: -10px; }
428
+ /* The open criterion's row, and a row with a form open under it, stay
429
+ lit. */
430
+ .dstf-crit-open, .dstf-row-open { background: var(--dstf-selection); }
431
+ /* A criterion's swatch: its status colour, filled while its drawing shows,
432
+ an outline of it while hidden; the open criterion's is locked on. */
433
+ .dstf-swatch {
434
+ width: 12px; height: 12px; border-radius: 2px; flex: none;
435
+ border: 2px solid; cursor: pointer; box-sizing: border-box;
436
+ }
437
+ .dstf-swatch-locked { cursor: default; opacity: .6; }
438
+ /* A family's folded criteria row (D152): its chevron sits in the swatch's
439
+ place — a 20 px target in the swatch's 12 px, so every row's name starts
440
+ in one column — turned right while the row is folded. A member's row is
441
+ a criterion's row, indented under its fold with a rule down its left;
442
+ folded away it is `hidden`, an attribute the row's `display: flex` would
443
+ otherwise beat. */
444
+ .dstf-fold-chev.dstf-chev { width: 20px; height: 20px; margin: 0 -4px; flex: none; }
445
+ .dstf-fold[data-folded="1"] .dstf-chev .q-icon { transform: rotate(-90deg); }
446
+ .dstf-crit[data-member] {
447
+ margin-left: 14px; width: calc(100% - 14px);
448
+ border-left: 2px solid var(--dstf-border);
449
+ }
450
+ .dstf-criteria > [hidden] { display: none !important; }
451
+ /* The editor belongs to the row above it: indented under it, with the
452
+ accent rule down its left saying how far it reaches. A node's form row
453
+ belongs to its row the same way (the 2026-09-21 board, 1A). */
454
+ .dstf-editor, .dstf-form-row {
455
+ margin: 0 0 8px 16px; padding: 12px;
456
+ border-left: 3px solid var(--dstf-accent);
457
+ border-top: 1px solid var(--dstf-border-hairline);
458
+ border-bottom: 1px solid var(--dstf-border-hairline);
459
+ }
460
+ /* The criterion editor's fields in ONE wrapping row (the board: the plot
461
+ and its editor fit a screen together). `.dstf-form` is the mode form's
462
+ slot, which hands its controls straight to that row rather than stacking
463
+ them in a column of its own; `.dstf-field` is one control, wide enough
464
+ for its label ("Width mode" clipped to "Wid…" in the Task 6 screenshot);
465
+ `.dstf-field-wide` is a multi-line one (a JSON list), on its own line. */
466
+ .dstf-fields { row-gap: 8px; }
467
+ .dstf-form { display: contents; }
468
+ .dstf-field { min-width: 140px; }
469
+ /* `order`, not source order: a tube's "Control points" is declared in the
470
+ MIDDLE of its mode form, and a full-width box there broke the row into
471
+ three lines with two single-line fields stranded below it. */
472
+ .dstf-fields .dstf-field-wide { order: 1; }
473
+ .dstf-field-wide { width: 100%; }
474
+ /* A tolerance's two sides (`forms.sides_control`): the lower field, the
475
+ link toggle and the upper field, one group on one line, the toggle level
476
+ with the two input lines. A group with a hint ("in effect: …") makes its
477
+ first field wide enough for it on one line — never wider than the row
478
+ leaves beside the toggle and the second field. Under 420 px of row (the
479
+ editor beside an open drawer in a 900 px window: 415) that width is
480
+ dropped rather than push the editor wider than its card: the group takes
481
+ the row, its first field what the row has left, and it wraps where even
482
+ that is too little. The row a group sits in is the container that says
483
+ how much room there is: the form's, or a table's rows. */
484
+ .dstf-fields, .dstf-table-scroll { container-type: inline-size; }
485
+ .dstf-sides { flex-wrap: nowrap; gap: 4px; }
486
+ .dstf-sides > .q-btn { flex: none; margin-top: 8px; }
487
+ /* The toggle's two states at a glance, as the board drew them: linked is
488
+ the accent, unlinked muted. Read off `aria-pressed` — an attribute the
489
+ server renders, not a class. */
490
+ .dstf-sides > .q-btn[aria-pressed="true"] { color: var(--dstf-accent); }
491
+ .dstf-sides > .q-btn[aria-pressed="false"] { color: var(--dstf-text-muted); }
492
+ /* A field's ⌖ (D149): muted at rest, the accent on a fill while its field
493
+ is ARMED — the next click on the plot sets it. Read off `aria-pressed`,
494
+ which the server renders. And the armed criterion's PLOT says so where
495
+ the click is made: its card carries `data-armed`, the plot an accent
496
+ outline, the line under it what the click sets. */
497
+ .dstf-arm { color: var(--dstf-text-muted); }
498
+ /* Close to its field's end: Quasar's 12 px before an appended control made
499
+ a points row, ten controls and eight of these, a line taller at 1280. */
500
+ .q-field__append:has(> .dstf-arm) { padding-left: 4px; }
501
+ .dstf-arm[aria-pressed="true"] {
502
+ color: var(--dstf-accent);
503
+ background: color-mix(in srgb, var(--dstf-accent) 18%, transparent);
504
+ }
505
+ .dstf-plot[data-armed] .dstf-plot-slot {
506
+ outline: 2px solid var(--dstf-accent); outline-offset: -2px;
507
+ }
508
+ .dstf-plot-armed { color: var(--dstf-accent); }
509
+ .dstf-sides-hinted > .q-field:first-child {
510
+ min-width: min(var(--dstf-hint-width), calc(100cqw - 180px));
511
+ }
512
+ @container (width < 420px) {
513
+ .dstf-sides { flex-wrap: wrap; }
514
+ .dstf-sides-hinted { flex: 1 1 100%; }
515
+ .dstf-sides-hinted > .q-field:first-child { min-width: 140px; flex: 1 1 140px; }
516
+ }
517
+ /* Focus (board 5A): "Showing 1 of N plots", the switcher naming it, and
518
+ the way back to all of them, on the accent tint so the page says at a
519
+ glance that what is below it is one plot of several. */
520
+ .dstf-focus-bar {
521
+ background: var(--dstf-selection); border: 1px solid var(--dstf-accent);
522
+ border-radius: 4px; padding: 4px 12px;
523
+ }
524
+ /* Eight rows of 42 px, and then the rows scroll (board 2A) — the
525
+ container this sits on holds the rows alone, never `Add row`. */
526
+ .dstf-table-scroll { max-height: 336px; overflow-y: auto; }
527
+ /* A table's row: its cells on one line where they fit and wrapped INSIDE
528
+ the row where they do not (a points row is ten controls) — the table
529
+ never scrolls sideways. Top-aligned: a cell that grows a line to say
530
+ "required" leaves the row's input lines level. */
531
+ .dstf-table-row { flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; }
532
+ .dstf-table-row > .q-field, .dstf-table-row > .dstf-sides > .q-field {
533
+ width: 120px;
534
+ }
535
+ .dstf-table-row > .q-btn { margin-top: 6px; }
536
+ .dstf-wordmark { font-size: 20px; font-weight: 700; }
537
+ /* The pending drawer's preview (Q6 A): the entry as test_spec.json will
538
+ hold it, one line per row, never wrapped — a long line scrolls INSIDE
539
+ the diff, not the drawer. A flex item's min-width is its content's:
540
+ without the min-width 0 here and on the section and body columns a long
541
+ line widened the diff past the drawer, which clipped it, and nothing
542
+ scrolled. The lines sit in a box as wide as the longest of them, so an
543
+ added line's tint runs the whole scrolled width. */
544
+ .dstf-diff {
545
+ font-family: var(--dstf-mono); font-size: 12px; overflow-x: auto;
546
+ width: 100%; min-width: 0; max-width: 100%;
547
+ border: 1px solid var(--dstf-border-hairline); border-radius: 3px;
548
+ }
549
+ .dstf-diff-lines { width: max-content; min-width: 100%; }
550
+ .dstf-diff-line { display: flex; white-space: pre; line-height: 18px; padding: 0 6px; }
551
+ .dstf-diff-line > * { flex: none; }
552
+ .dstf-diff-add { background: var(--dstf-pass-tint); color: var(--dstf-pass-dark); }
553
+ .dstf-diff-del { background: var(--dstf-fail-tint); color: var(--dstf-fail-dark); }
554
+ .dstf-diff-gap, .dstf-diff-muted { color: var(--dstf-text-muted); }
555
+ .dstf-mono { font-family: var(--dstf-mono); }
556
+ .dstf-why { color: var(--dstf-text-muted); font-size: 12px; }
557
+ /* A card's title reads the expression its model wrote; the result-file
558
+ name — what a criterion, an override or a --filter names — rides beside
559
+ it, muted. Drawn only when the two differ. */
560
+ .dstf-varname { color: var(--dstf-text-muted); font-size: 12px; font-family: var(--dstf-mono); }
561
+ .dstf-progress { font-size: 12px; color: var(--dstf-text-secondary); }
562
+ .dstf-group {
563
+ font-size: 10px; text-transform: uppercase; letter-spacing: .03em;
564
+ color: var(--dstf-text-muted); padding: 12px 0 2px; overflow-wrap: anywhere;
565
+ }
566
+ /* A short, fixed title — the picker's sections, the Statistics card's —
567
+ never breaks (at 10 px "On this plot" measured 70 px in a 69.6 px box)
568
+ and spaces its words: uppercase at 10 px a space is 2.3 px, and "S P"
569
+ read "ON THISPLOT" in Chromium on one line. A drawer's package title
570
+ keeps `anywhere`. */
571
+ .dstf-pick-list .dstf-group, .dstf-stats .dstf-group {
572
+ white-space: nowrap; word-spacing: .2em;
573
+ }
574
+ /* The Statistics card (U3): a row per statistic, the numbers in fixed
575
+ right-aligned columns so Run and Baseline read down, the `+` or the
576
+ criterion's pill at the end; a changed row wears the report's warn
577
+ tint. */
578
+ .dstf-stats .dstf-group { padding-left: 8px; }
579
+ .dstf-stat-row {
580
+ width: 100%; min-height: 28px; padding: 0 8px;
581
+ border-top: 1px solid var(--dstf-border-hairline);
582
+ }
583
+ .dstf-stat-head { color: var(--dstf-text-muted); font-size: 12px; border-top: none; }
584
+ .dstf-stat-label {
585
+ flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
586
+ white-space: nowrap;
587
+ }
588
+ /* In px, not ch: the headings are not in the numbers' mono font, and a
589
+ `ch` column is as wide as its own font says. */
590
+ .dstf-stat-num { flex: none; width: 84px; text-align: right; }
591
+ .dstf-stat-delta { flex: none; width: 56px; text-align: right; }
592
+ .dstf-stat-end {
593
+ flex: none; width: 150px; display: flex; align-items: center;
594
+ justify-content: flex-end; gap: 6px;
595
+ }
596
+ .dstf-stat-changed { background: var(--dstf-warn-tint); }
597
+ /* Open, a changed row wears the selection as every open criteria row does:
598
+ the tint, declared later, hid which row the editor was under. */
599
+ .dstf-stats .dstf-crit-open { background: var(--dstf-selection); }
600
+ /* The editor under a Statistics row, or a tree row (U5), keeps inside the
601
+ card: `w-full` plus the editor's 16 px indent ran 16 px past its right
602
+ edge (B12). */
603
+ .dstf-stats .dstf-editor, .dstf-tree .dstf-editor { width: calc(100% - 16px); }
604
+ /* The Tests drawer's rows at a real library's lengths: the name takes what
605
+ the badge and the edits pill leave it and is cut with an ellipsis (the
606
+ whole id is its title), and they never shrink. A TRANSFORM id in Full,
607
+ or a 40-character short name, pushed the badge out of the drawer at
608
+ 900 px, and a package label ran past it. */
609
+ .dstf-tests .dstf-name { flex: 1 1 auto; min-width: 0; }
610
+ .dstf-tests .dstf-name .q-btn__content > .block {
611
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
612
+ }
613
+ /* A Full name is cut at its start, where ids sharing a package prefix
614
+ differ least (`test_list._full_name`: the id is a <bdi> inside). */
615
+ .dstf-tests .dstf-name .dstf-cut-start { direction: rtl; }
616
+ .dstf-tests .dstf-badge, .dstf-tests .dstf-pill-named { flex: none; }
617
+ .dstf-row {
618
+ min-height: 32px; border-bottom: 1px solid var(--dstf-border-hairline);
619
+ background: var(--dstf-surface); transition: background .1s;
620
+ }
621
+ .dstf-row:hover { background: var(--dstf-surface-hover); }
622
+ .dstf-badge.q-badge {
623
+ color: #ffffff; font-size: 10.5px; font-weight: 700; text-transform: uppercase;
624
+ letter-spacing: .03em; border-radius: 3px;
625
+ }
626
+ /* The Test page's rail (3c): labelled buttons in 40 px. NiceGUI pads a
627
+ drawer by 1rem, which put the rail's buttons at 16-56 px of a 40 px
628
+ drawer and clipped "Tests" to "Tes" (the review's screenshot, and again
629
+ with 10 px labels): the drawer carries no padding. The rail and the
630
+ panel may give up the 1 px of the drawer's border rather than overflow
631
+ it. The open drawer's button sits on the selection tint. */
632
+ .nicegui-drawer.dstf-rail-drawer { padding: 0; gap: 0; }
633
+ .dstf-rail { width: 40px; min-width: 0; padding-top: 4px; }
634
+ .dstf-rail .q-btn { width: 100%; padding: 4px 0; min-width: 0; }
635
+ .dstf-rail-label { font-size: 10px; letter-spacing: 0; line-height: 1.2; }
636
+ .dstf-rail-on { background: var(--dstf-selection); color: var(--dstf-accent); }
637
+ /* The panel's width is the tab's (`TabState.drawer_width`, set inline by
638
+ `Rail`); its right edge is the resize grip (K5), 6 px of the drawer over
639
+ the panel's border. */
640
+ .dstf-drawer-panel { min-width: 0; flex: none; }
641
+ .dstf-rail-row { position: relative; }
642
+ .dstf-drawer-handle {
643
+ position: absolute; top: 0; right: 0; bottom: 0; width: 6px;
644
+ cursor: col-resize; touch-action: none; z-index: 1;
645
+ }
646
+ .dstf-drawer-handle:hover { background: var(--dstf-selection); }
647
+ /* Focused from the keyboard, the grip is the accent: ←/→ move it (K5). */
648
+ .dstf-drawer-handle:focus-visible { outline: none; background: var(--dstf-accent); }
649
+ /* The Variables drawer's rows (3c): "+ new plot" is there on the hovered
650
+ row only (the owner's ruling, Q6 A) — `visibility`, so the row does not
651
+ reflow as the mouse moves down the list — and on a row with focus in
652
+ it: a hidden button takes no focus, and Tab from a check skipped its
653
+ own. A family's members hang under it, indented; a family's swatch marks
654
+ an array, in the accent ramp (a plot picks a family's hue by its order
655
+ on that plot, not this). The target line is a tag on the accent tint
656
+ with the selected plot's ring inside it, as the owner's board drew it: a
657
+ bare ring beside the text read as an empty checkbox. */
658
+ .dstf-var-row { min-height: 28px; }
659
+ /* Two lines (2026-09-29): the label with the row's width on the first; the
660
+ muted name, "plot N" and "+ new plot" on the second, under the label
661
+ (past the check). A row with nothing but "+ new plot" is one line (K6). */
662
+ .dstf-var-line1 { min-height: 26px; }
663
+ .dstf-var-line2 { padding-left: 28px; min-height: 18px; font-size: 11px; }
664
+ .dstf-var-line2 .q-btn { min-height: 18px; }
665
+ .dstf-var-line2 .dstf-varname {
666
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
667
+ }
668
+ .dstf-var-row:hover { background: var(--dstf-surface-hover); }
669
+ .dstf-var-row .dstf-var-new { visibility: hidden; }
670
+ .dstf-var-row:hover .dstf-var-new,
671
+ .dstf-var-row:focus-within .dstf-var-new { visibility: visible; }
672
+ .dstf-var-member { padding-left: 24px; }
673
+ .dstf-var-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
674
+ .dstf-var-where, .dstf-var-row .dstf-var-new { flex: none; white-space: nowrap; }
675
+ .dstf-var-ramp { display: inline-flex; flex: none; }
676
+ .dstf-var-ramp > span { width: 5px; height: 9px; }
677
+ .dstf-var-target {
678
+ cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 3px;
679
+ background: var(--dstf-selection); color: var(--dstf-accent);
680
+ }
681
+ .dstf-var-ring {
682
+ width: 12px; height: 9px; border-radius: 2px; flex: none;
683
+ border: 2px solid var(--dstf-accent); box-sizing: content-box;
684
+ }
685
+ /* The add form's picker (U2): a list that scrolls inside the form row,
686
+ never a 200-row form; the chosen row on the selection tint, the
687
+ keyboard's cursor ringed; the Chosen line under it (K7). */
688
+ .dstf-pick-list { max-height: 240px; overflow-y: auto; }
689
+ .dstf-pick-row { cursor: pointer; min-height: 24px; padding: 0 4px; }
690
+ .dstf-pick-row:hover { background: var(--dstf-surface-hover); }
691
+ .dstf-pick-on { background: var(--dstf-selection); }
692
+ .dstf-pick-cursor { box-shadow: inset 0 0 0 2px var(--dstf-accent); }
693
+ .dstf-pick-chosen { color: var(--dstf-text-muted); padding: 2px 4px 0; }
694
+ /* The drawers' one control set (Q7): a menu's current choice carries its
695
+ check; a count pill is a status pill that is a button, ringed while it
696
+ filters. */
697
+ .dstf-menu-check { visibility: hidden; }
698
+ .dstf-menu-checked .dstf-menu-check { visibility: visible; }
699
+ .dstf-count-pill.q-btn { min-height: 20px; padding: 0 6px; font-size: 10.5px; }
700
+ .dstf-count-pill-on.q-btn { box-shadow: 0 0 0 2px var(--dstf-accent); }
701
+ .dstf-card {
702
+ border: 1px solid var(--dstf-border); border-radius: 4px; box-shadow: none;
703
+ background: var(--dstf-surface); padding: 16px;
704
+ }
705
+ /* The selected plot, the one the Variables drawer's checkboxes act on: two
706
+ classes and after `.dstf-card`, whose `box-shadow: none` otherwise wins
707
+ and the ring is classed but never painted (3c's LOOK, in Chromium). */
708
+ .dstf-card.dstf-plot-selected { box-shadow: inset 0 0 0 2px var(--dstf-accent); }
709
+ """
710
+ + _TINTS
711
+ + _PILLS
712
+ + _SWATCHES
713
+ # After the fills, so an outline wins over the colour it is drawn in.
714
+ + ".dstf-swatch-off { background: transparent; }\n"
715
+ # Two classes: that alone beats the one-class status tints, whatever the
716
+ # order (as one class it tied with them and read the fail tint,
717
+ # rgb(255,235,238) — the 3c review). The order that matters is after
718
+ # `.dstf-row:hover`, which has the same specificity: last, the current
719
+ # row stays lit under the mouse.
720
+ + ".dstf-row.dstf-row-current { background: var(--dstf-selection); }\n"
721
+ )