texas2step 2.1.0__tar.gz → 2.2.0__tar.gz

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 (68) hide show
  1. {texas2step-2.1.0 → texas2step-2.2.0}/PKG-INFO +1 -1
  2. {texas2step-2.1.0 → texas2step-2.2.0}/docs/configuration.md +31 -5
  3. {texas2step-2.1.0 → texas2step-2.2.0}/docs/development.md +31 -5
  4. {texas2step-2.1.0 → texas2step-2.2.0}/docs/experience.md +3 -2
  5. {texas2step-2.1.0 → texas2step-2.2.0}/docs/methodology.md +32 -14
  6. {texas2step-2.1.0 → texas2step-2.2.0}/docs/roadmap.md +16 -0
  7. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/__init__.py +1 -1
  8. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config.py +18 -5
  9. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_template.toml +8 -1
  10. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/page.py +37 -2
  11. texas2step-2.2.0/src/texas2step/static/assets/app.css +1 -0
  12. texas2step-2.2.0/src/texas2step/static/assets/app.js +8 -0
  13. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/index.html +3 -2
  14. texas2step-2.1.0/src/texas2step/static/assets/app.css +0 -1
  15. texas2step-2.1.0/src/texas2step/static/assets/app.js +0 -8
  16. {texas2step-2.1.0 → texas2step-2.2.0}/.gitignore +0 -0
  17. {texas2step-2.1.0 → texas2step-2.2.0}/LICENSE.md +0 -0
  18. {texas2step-2.1.0 → texas2step-2.2.0}/README.md +0 -0
  19. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-http.conf +0 -0
  20. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-log.conf +0 -0
  21. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-speedtest.conf +0 -0
  22. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step.conf +0 -0
  23. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step-update.service +0 -0
  24. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step-update.timer +0 -0
  25. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step.default +0 -0
  26. {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step.service +0 -0
  27. {texas2step-2.1.0 → texas2step-2.2.0}/docs/access.md +0 -0
  28. {texas2step-2.1.0 → texas2step-2.2.0}/docs/api.md +0 -0
  29. {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/hero-dark.jpg +0 -0
  30. {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/results-dark.jpg +0 -0
  31. {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/results-light.jpg +0 -0
  32. {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/upload-light.jpg +0 -0
  33. {texas2step-2.1.0 → texas2step-2.2.0}/docs/install.md +0 -0
  34. {texas2step-2.1.0 → texas2step-2.2.0}/docs/security.md +0 -0
  35. {texas2step-2.1.0 → texas2step-2.2.0}/docs/traffic.md +0 -0
  36. {texas2step-2.1.0 → texas2step-2.2.0}/legacy/README.md +0 -0
  37. {texas2step-2.1.0 → texas2step-2.2.0}/pyproject.toml +0 -0
  38. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/__main__.py +0 -0
  39. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/access.py +0 -0
  40. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/__init__.py +0 -0
  41. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/access.py +0 -0
  42. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/measure.py +0 -0
  43. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/meta.py +0 -0
  44. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/results.py +0 -0
  45. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/wire.py +0 -0
  46. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/app.py +0 -0
  47. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/cli.py +0 -0
  48. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/cluster.py +0 -0
  49. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_edit.py +0 -0
  50. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_merge.py +0 -0
  51. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/engine.py +0 -0
  52. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/iface.py +0 -0
  53. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/lockouts.py +0 -0
  54. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/logs.py +0 -0
  55. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/payload.py +0 -0
  56. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/quality.py +0 -0
  57. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/rtc.py +0 -0
  58. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/LICENSE +0 -0
  59. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/NOTICE +0 -0
  60. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js +0 -0
  61. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +0 -0
  62. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui.css +0 -0
  63. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/favicon.svg +0 -0
  64. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/stats.py +0 -0
  65. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/store.py +0 -0
  66. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/traffic.py +0 -0
  67. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/update.py +0 -0
  68. {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/words.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: texas2step
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Web-centric throughput, latency, jitter and packet-loss measurement between endpoints.
5
5
  Project-URL: Homepage, https://github.com/robert-patrick-texas/texas2step
6
6
  Project-URL: Documentation, https://robert-patrick-texas.github.io/texas2step/
@@ -70,6 +70,8 @@ file up to date with the new version's template and keeps your values; see
70
70
  | `brand_logo` | `""` | Path to an SVG, PNG, JPEG or WebP file shown in place of the star |
71
71
  | `brand_notice` | `""` | A line of text across the top of every page |
72
72
  | `brand_links` | `[]` | Links in the header and footer, as `[[brand_links]]` tables (TOML only) |
73
+ | `brand_footer` | `""` | Your own footer's text, near the bottom of every page: each line a line, a blank line a new paragraph, and `[label](url)` a link. See [Branding and themes](#branding-and-themes). |
74
+ | `brand_footer_links` | `[]` | Your own footer's links, beside its text, as `[[brand_footer_links]]` tables (TOML only) |
73
75
  | `brand_credit` | `link` | The footer's "texas2step <version>": `link` to the project's site, plain `text`, or `none` to leave the project's name and version out of the footer |
74
76
  | `default_theme` | `system` | `system`, `light`, `dark`, `america`, `energy`, `federal`, `military` or `texas` |
75
77
 
@@ -91,8 +93,8 @@ sudo systemctl restart texas2step
91
93
  setting commented out, the line is filled in where it stands; otherwise it is added
92
94
  above the first `[[table]]`. A value is read as TOML when it is valid TOML (`61000`,
93
95
  `true`, `["a", "b"]`), and as text otherwise. Unknown settings, and values the server
94
- would refuse, are rejected before anything is written. Edit `[[locations]]` and
95
- `[[brand_links]]` in the file, or use `add-location`.
96
+ would refuse, are rejected before anything is written. Edit `[[locations]]`,
97
+ `[[brand_links]]` and `[[brand_footer_links]]` in the file, or use `add-location`.
96
98
  * **`config add-location URL`** lists another texas2step server in the location picker.
97
99
  It asks that server for its id, name and location (`/api/v1/info`), and for its
98
100
  IPv4-only and IPv6-only URLs (its own entry in `/api/v1/locations`), using the
@@ -119,6 +121,10 @@ brand_url = "https://www.acme.example" # "Acme IT" links here
119
121
  brand_logo = "/etc/texas2step/logo.svg" # replaces the star mark
120
122
  brand_notice = "Authorized use only. Activity on this system may be monitored."
121
123
  default_theme = "federal"
124
+ # Your own footer, near the bottom of every page (before any [[table]], as TOML needs).
125
+ brand_footer = """
126
+ © 2026 Acme IT. For official use only.
127
+ Questions? Ask the [service desk](https://help.acme.example) or call [555-0100](tel:5550100)."""
122
128
 
123
129
  [[brand_links]]
124
130
  label = "Service desk"
@@ -127,6 +133,14 @@ url = "https://help.acme.example"
127
133
  [[brand_links]]
128
134
  label = "Email the NOC"
129
135
  url = "mailto:noc@acme.example"
136
+
137
+ [[brand_footer_links]]
138
+ label = "Privacy"
139
+ url = "https://www.acme.example/privacy"
140
+
141
+ [[brand_footer_links]]
142
+ label = "Accessibility"
143
+ url = "https://www.acme.example/accessibility"
130
144
  ```
131
145
 
132
146
  * **Where it shows.** The name and logo lead the header. The organization sits beside them
@@ -137,13 +151,25 @@ url = "mailto:noc@acme.example"
137
151
  words without the link, and `brand_credit = "none"` leaves the project's name, version
138
152
  and "Powered by" out of the footer, for sites that carry only their own branding. The
139
153
  version is still at `/api/v1/health`.
154
+ * **Your own footer.** `brand_footer` and `[[brand_footer_links]]` add a footer of your
155
+ own near the bottom of every page, the access-code screen included, above the line with
156
+ the organization and the credit. texas2step lays it out the same way on every site and
157
+ in every theme. On wide screens your text is on the left and your links are stacked
158
+ on the right. On phones the links follow the text, and with only one of the two it is
159
+ centered. Each line of `brand_footer` starts a new line, and a blank line starts a
160
+ paragraph. The text is plain, but `[label](url)` makes a link of the label, by the same
161
+ rules as the other links below. A URL ends at the first `)`. HTML isn't allowed, so it
162
+ shows as typed. In TOML, `"""` lets the text run over several lines. The API explorer
163
+ (`/api/docs`) is a separate page and has no footer of yours.
140
164
  * **Logo.** SVG, PNG, JPEG, WebP or GIF, about 30 px tall on screen; wide logos keep their
141
165
  shape, up to 180 px wide. The `texas2step` service user must be able to read the file
142
166
  (`/etc/texas2step/` is a good place). If the file is missing or unreadable, the server
143
167
  logs a warning and keeps the star.
144
- * **Links** must start with `https://`, `http://`, `mailto:` or `tel:`. Anything else stops
145
- the server at startup with an error naming the setting. `[[brand_links]]` can only be
146
- set in the TOML file, not through environment variables.
168
+ * **Links** must start with `https://`, `http://`, `mailto:` or `tel:`, in
169
+ `[[brand_links]]`, `[[brand_footer_links]]` and `brand_footer` alike. Anything else
170
+ stops the server at startup with an error naming the setting. `[[brand_links]]` and
171
+ `[[brand_footer_links]]` can only be set in the TOML file, not through environment
172
+ variables.
147
173
  * **Themes.** Visitors choose from the palette button in the header. Their choice is
148
174
  remembered in their browser. `default_theme` sets what a first-time visitor sees;
149
175
  `system` follows their light or dark OS setting.
@@ -218,8 +218,37 @@ gh release create "v$v" "dist/texas2step-$v-1-amd64.deb" dist/texas2step-setup \
218
218
  --title "texas2step $v" --generate-notes
219
219
  ```
220
220
 
221
- 6. Publish the Python package to [PyPI](https://pypi.org/project/texas2step/), for
222
- `uv tool install texas2step` and `pipx install texas2step`, with a PyPI API token:
221
+ 6. Publishing the release publishes the Python package to
222
+ [PyPI](https://pypi.org/project/texas2step/) too, for `uv tool install texas2step` and
223
+ `pipx install texas2step`. `.github/workflows/pypi.yml` checks that the tag is
224
+ `v<version>`, runs the tests, builds the sdist and wheel from the tag, checks their
225
+ metadata, installs the wheel on its own and tests the server it starts. Then it uploads
226
+ both files with PyPI's trusted publishing, with no API token. Watch it, then check the
227
+ version's page on PyPI:
228
+
229
+ ```bash
230
+ gh run watch "$(gh run list --workflow pypi.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
231
+ ```
232
+
233
+ A version can be uploaded to PyPI only once. A run that fails before the upload can be
234
+ run again (`gh run rerun --failed <id>`). A fix that needs a new commit needs a new
235
+ version. To rehearse everything but the upload, run the workflow by hand: Actions →
236
+ pypi → Run workflow, or `gh workflow run pypi.yml`.
237
+
238
+ PyPI's copy of the README has its links made absolute, to GitHub, by
239
+ `hatch-fancy-pypi-readme` (`pyproject.toml`).
240
+
241
+ ### Trusted publishing: the one-time setup
242
+
243
+ PyPI accepts the workflow's uploads because the project trusts it:
244
+
245
+ * On PyPI, under the project's **Settings → Publishing**, a GitHub trusted publisher
246
+ with the repository's owner and name, the workflow `pypi.yml` and the environment
247
+ `pypi`.
248
+ * On GitHub, under **Settings → Environments**, an environment named `pypi` whose
249
+ deployments are limited to tags matching `v*`, so only a release tag can publish.
250
+
251
+ Without the workflow, a maintainer with a PyPI API token can still publish by hand:
223
252
 
224
253
  ```bash
225
254
  uv build --out-dir build/pypi
@@ -227,9 +256,6 @@ UV_PUBLISH_TOKEN=<token> uv publish build/pypi/*
227
256
  rm -rf build/pypi
228
257
  ```
229
258
 
230
- PyPI's copy of the README has its links made absolute, to GitHub, by
231
- `hatch-fancy-pypi-readme` (`pyproject.toml`). A version can be uploaded to PyPI only once.
232
-
233
259
  `texas2step update` finds the two files on the release by their names
234
260
  (`texas2step-<version>-<revision>-<arch>.deb` and `texas2step-setup`) and checks each
235
261
  against the SHA-256 GitHub records for it, so attach them under exactly those names. It
@@ -116,9 +116,10 @@ the score at 4.3, excellent; the rule rates it good.
116
116
  ### Calls over time
117
117
 
118
118
  Monitor's call profiles send a call's packets for as long as the page runs, and score each
119
- 10 seconds as a call, so a problem that comes and goes shows as the cells it spoiled.
119
+ 10 seconds as a call, so a problem that comes and goes shows in the scores of the 10
120
+ seconds it spoiled.
120
121
  **Below good** is the share of the time a caller would have noticed something wrong
121
- (below 4.0), and **Worst 10 s** the moment they'd remember. A video call also counts
122
+ (below 4.0), and **Worst 10s** the moment they'd remember. A video call also counts
122
123
  **frames hit**: video frames with a packet lost or late, each one a freeze or a smear on
123
124
  the screen. A video call keeps working with a few frames hit; its voice, scored beside
124
125
  it, is what decides whether people can talk.
@@ -20,6 +20,17 @@ responsiveness), so their results are comparable.
20
20
  | 4 | Upload, with latency under load and responsiveness | N parallel `POST /api/v1/up`, WebSocket probes, a new connection a second | 4 streams × 10 s |
21
21
  | 5 | Packet loss, UDP RTT and jitter, call quality | WebRTC data channel, unordered, no retransmits | 500 probes of 200 bytes, 10 ms apart |
22
22
 
23
+ Each page keeps what it shows while another page is open, but only the page on screen
24
+ measures, so one page's traffic never skews another's figures. Going to another page
25
+ during a test stops it as Stop does, and its partial results stay.
26
+
27
+ While a test runs, and while Monitor or Diag is open, the page asks the browser to keep
28
+ the screen on (the Screen Wake Lock API). A phone whose screen goes off suspends the
29
+ page, and a locked computer slows its timers, so either would put gaps in the figures.
30
+ The **Keep screen on** switch, in Test options, Monitor's Options and on Diag, turns
31
+ this off for that browser. Browsers offer it only over HTTPS. They let the screen sleep
32
+ as usual when you switch to another app or tab, and some refuse it in battery saver.
33
+
23
34
  ## Latency
24
35
 
25
36
  Each probe is a `GET /api/v1/ping` on an already-open keep-alive connection, so TCP and
@@ -275,21 +286,27 @@ whole. A frame goes out as a burst because a video call sends it that way, and b
275
286
  what shallow buffers and policers drop.
276
287
 
277
288
  With a call profile, the server [stamps](#packet-loss-udp) each echo, and every
278
- 10 seconds of the call is scored as the Test page scores its packet-loss test, each
279
- direction on its own ([Call quality](#call-quality)):
280
-
281
- * **A strip of 10-second cells** under the chart, coloured by rating, with the score
282
- written in where it fits. It takes the place of the loss-per-second strip; lost
283
- packets still show as red ticks along the top.
284
- * **Call quality**, the median 10-second score in the window; **Worst 10 s**, and how
285
- long ago; **Below good**, the share of 10-second cells rated below good (4.0).
289
+ 10 seconds of the call, counted from the start, is scored as the Test page scores its
290
+ packet-loss test, each direction on its own ([Call quality](#call-quality)):
291
+
292
+ * **On the loss-per-second strip**, each 10 seconds, once whole, gets a line beneath it
293
+ in its rating's colour and its score on a label over its middle, where there's room.
294
+ The seconds' own loss stays in view around and under them. Hovering a second gives its
295
+ loss and the score of its 10 seconds, each way.
296
+ * **Call quality**, the median 10-second score in the window; **Worst 10s**, and how
297
+ long ago; **Below good**, the share of the 10-second scores below good (4.0).
286
298
  * **Frames hit** (Video call): video frames with a packet lost or more than 60 ms late,
287
299
  in the worse direction. Each is a freeze or a smear on the screen.
288
300
 
289
301
  Over hours the two computers' clocks drift apart, by tens of milliseconds an hour, so each
290
- cell measures delays against its own fastest packet, where the drift is a fraction of a
291
- millisecond. A server without stamps scores each cell on round trips, and over the TCP
292
- fallback only the voice packets are sent.
302
+ 10 seconds measures delays against its own fastest packet, where the drift is a fraction
303
+ of a millisecond. A server without stamps scores each 10 seconds on round trips, and over
304
+ the TCP fallback only the voice packets are sent.
305
+
306
+ **Stop** holds the chart and its figures where they stopped, to show or to take a
307
+ screenshot of. The window can still be changed, to look back over the last 15 minutes.
308
+ The 10 seconds cut short by Stop aren't scored. Going to another page stops the monitor
309
+ the same way, and the page says so when you come back to it.
293
310
 
294
311
  Voice and Video calls count toward a [traffic limit](traffic.md#monitors-call-profiles).
295
312
  When one is reached, the server holds the session to path probes and says so, and the
@@ -308,8 +325,9 @@ the client's own link, and both would read low.
308
325
 
309
326
  ## Diag
310
327
 
311
- The Diag page tries each way of reaching the server once a second, for as long as the
312
- page is open: HTTPS, WebSocket and UDP, over IPv4 and IPv6.
328
+ The Diag page tries each way of reaching the server once a second while the page is
329
+ shown: HTTPS, WebSocket and UDP, over IPv4 and IPv6. Going to another page pauses it
330
+ and closes its connections. Its rows and events stay, and the timelines show the gap.
313
331
 
314
332
  * **HTTPS** is a small request to `/api/v1/diag/http` on the server's IPv4-only or
315
333
  IPv6-only address ([IPv4 and IPv6](configuration.md#ipv4-and-ipv6)). The answer gives
@@ -339,7 +357,7 @@ could see it, gives a random name ending in `.local` instead. So by default the
339
357
  egress box shows only the addresses the server sees. Where the browser reveals it, the
340
358
  box adds **This computer**, the address of the interface the UDP path leaves by, and a
341
359
  **NAT** row comparing it with the address the server sees. Browser test runs save it
342
- with their results, in History's **Local** column.
360
+ with their results (`client.local_ip`).
343
361
 
344
362
  To have managed browsers reveal it to this site only:
345
363
 
@@ -102,6 +102,22 @@ links and footer credit. Three places still say texas2step:
102
102
 
103
103
  Newest first. Each release's notes have the details and its upgrade steps.
104
104
 
105
+ ### [2.2.0](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.0) (2026-10-24)
106
+
107
+ - [x] Each page keeps what it shows across tabs; only the page on screen measures.
108
+ - [x] The screen stays on while Monitor or Diag is open and while a test runs, with a
109
+ Keep screen on switch.
110
+ - [x] An organization's own footer: `brand_footer` and `[[brand_footer_links]]`.
111
+ - [x] Monitor's call tiles put their rating and "ago" under the score on narrow screens.
112
+ - [x] Releases publish to PyPI from GitHub, by trusted publishing.
113
+
114
+ ### [2.1.1](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.1.1) (2026-10-24)
115
+
116
+ - [x] Monitor holds still at Stop, to show or take a screenshot of.
117
+ - [x] A call's scores sit on Monitor's loss-per-second strip, each over its 10 seconds.
118
+ - [x] History without the Local column; Diag's NAT and egress box shows this computer's
119
+ address.
120
+
105
121
  ### [2.1.0](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.1.0) (2026-10-24)
106
122
 
107
123
  - [x] Call quality each way: a MOS from the ITU-T E-model for what you hear and what the
@@ -1,3 +1,3 @@
1
1
  """texas2step: web-centric throughput, latency, jitter and packet-loss measurement."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.2.0"
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import dataclasses
14
14
  import ipaddress
15
15
  import os
16
+ import re
16
17
  import tomllib
17
18
  import types
18
19
  import typing
@@ -28,6 +29,8 @@ PAGES = ("test", "monitor", "diag", "history", "api") # the web UI's tabs, in o
28
29
  THEMES = ("system", "light", "dark", "america", "energy", "federal", "military", "texas")
29
30
  # Branding URLs end up in href attributes, so only these schemes are accepted.
30
31
  LINK_SCHEMES = ("https://", "http://", "mailto:", "tel:")
32
+ # A link inside brand_footer's text, written as in Markdown: [label](url).
33
+ FOOTER_LINK = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)")
31
34
  CREDITS = ("link", "text", "none") # brand_credit
32
35
  TRAFFIC_COUNTS = ("sent", "received", "both")
33
36
  MONITOR_PROFILES = ("path", "voice", "video") # monitor_profile
@@ -178,6 +181,10 @@ class Settings:
178
181
  brand_logo: str = "" # an SVG, PNG, JPEG or WebP file shown in place of the star mark
179
182
  brand_notice: str = "" # a line across the top of every page, e.g. an acceptable-use notice
180
183
  brand_links: list[Link] = field(default_factory=list)
184
+ # The organization's own footer: its text, line by line, with [label](url) links in it,
185
+ # and its links beside the text.
186
+ brand_footer: str = ""
187
+ brand_footer_links: list[Link] = field(default_factory=list)
181
188
  default_theme: str = "system" # one of THEMES; visitors can still pick another
182
189
  # The footer's "texas2step <version>": linked to the project's site, plain text, or none.
183
190
  brand_credit: str = "link"
@@ -259,10 +266,16 @@ class Settings:
259
266
  urls = [("brand_url", self.brand_url)] if self.brand_url else []
260
267
  if self.traffic_url:
261
268
  urls.append(("traffic_url", self.traffic_url))
262
- for link in self.brand_links:
263
- if not link.label.strip():
264
- raise ValueError("brand_links: every link needs a label")
265
- urls.append((f"brand_links ({link.label})", link.url))
269
+ for name, links in (
270
+ ("brand_links", self.brand_links),
271
+ ("brand_footer_links", self.brand_footer_links),
272
+ ):
273
+ for link in links:
274
+ if not link.label.strip():
275
+ raise ValueError(f"{name}: every link needs a label")
276
+ urls.append((f"{name} ({link.label})", link.url))
277
+ for label, url in FOOTER_LINK.findall(self.brand_footer):
278
+ urls.append((f"brand_footer ({label})", url))
266
279
  for name, url in urls:
267
280
  if not url.lower().startswith(LINK_SCHEMES):
268
281
  raise ValueError(f"{name}: URL must start with {', '.join(LINK_SCHEMES)}")
@@ -356,7 +369,7 @@ def load_settings(config_path: str | os.PathLike[str] | None = None) -> Settings
356
369
 
357
370
  for name, hint in hints.items():
358
371
  env = os.environ.get(ENV_PREFIX + name.upper())
359
- if env is not None and name not in {"locations", "brand_links"}:
372
+ if env is not None and name not in {"locations", "brand_links", "brand_footer_links"}:
360
373
  values[name] = _coerce(env, hint, name)
361
374
 
362
375
  return Settings(**values)
@@ -96,9 +96,11 @@ results_path = "/var/lib/texas2step/results.db"
96
96
  # brand_url = "" # the organization's website
97
97
  # brand_logo = "" # e.g. "/etc/texas2step/logo.svg" (SVG, PNG, JPEG, WebP)
98
98
  # brand_notice = "" # a line across the top of every page
99
+ # brand_footer = "" # your footer's text: "\n" starts a line, [label](url) is a link
99
100
  # default_theme = "system" # system, light, dark, america, energy, federal, military or texas
100
101
  # brand_credit = "link" # footer's "texas2step <version>": link, text or none
101
- # Header and footer links: see [[brand_links]] at the end of this file.
102
+ # Header and footer links: see [[brand_links]] at the end of this file, and your footer's
103
+ # own links, beside its text, in [[brand_footer_links]].
102
104
 
103
105
  # --- Other texas2step servers in the location picker ---------------------------------------
104
106
  # [[locations]]
@@ -111,3 +113,8 @@ results_path = "/var/lib/texas2step/results.db"
111
113
  # [[brand_links]]
112
114
  # label = "Service desk"
113
115
  # url = "https://help.example.net" # https://, http://, mailto: or tel:
116
+
117
+ # --- Your footer's links, beside brand_footer's text (TOML only) -----------------------------
118
+ # [[brand_footer_links]]
119
+ # label = "Privacy"
120
+ # url = "https://www.example.net/privacy"
@@ -6,7 +6,8 @@ fills in what only it knows:
6
6
  - the path prefix it is published under (``<base href>``),
7
7
  - the version (read by the UI from a meta tag),
8
8
  - the site's default theme, and which pages it shows,
9
- - the branding: name, logo, organization, links, notice and the footer's credit.
9
+ - the branding: name, logo, organization, links, notice, the organization's own footer
10
+ and the footer's credit.
10
11
 
11
12
  Branding is written into the page itself rather than added by script, so a renamed site
12
13
  never flashes the default name. Every value is HTML-escaped. Nothing from the request
@@ -21,7 +22,7 @@ import re
21
22
  from html import escape
22
23
  from pathlib import Path
23
24
 
24
- from .config import PAGES, Settings
25
+ from .config import FOOTER_LINK, PAGES, Settings
25
26
 
26
27
  log = logging.getLogger(__name__)
27
28
 
@@ -118,8 +119,33 @@ class Shell:
118
119
  if footer:
119
120
  dot = '<span class="dot" aria-hidden="true">·</span>'
120
121
  page = _fill(page, "footer", dot.join(footer) + dot)
122
+ if own := self._own_footer():
123
+ page = _fill(page, "ownfooter", own)
121
124
  return page
122
125
 
126
+ def _own_footer(self) -> str:
127
+ """The organization's footer: its text on the left, its links on the right."""
128
+ s = self.settings
129
+ # A blank line starts a paragraph; each other line starts a line.
130
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", s.brand_footer.strip()) if p.strip()]
131
+ text = "".join(
132
+ "<p>" + "<br />".join(_inline(line.strip()) for line in p.splitlines()) + "</p>"
133
+ for p in paragraphs
134
+ )
135
+ links = "".join(f"<li>{_link(link.label, link.url)}</li>" for link in s.brand_footer_links)
136
+ parts = []
137
+ if text:
138
+ parts.append(f'<div class="own-footer-text">{text}</div>')
139
+ if links:
140
+ parts.append(
141
+ f'<nav class="own-footer-links" aria-label="Footer links"><ul>{links}</ul></nav>'
142
+ )
143
+ if not parts:
144
+ return ""
145
+ body = f'<div class="own-footer-body{" solo" if len(parts) == 1 else ""}">'
146
+ # The outer div is a row of its own, above the credit line.
147
+ return f'<div class="own-footer">{body}{"".join(parts)}</div></div>'
148
+
123
149
  def _organization(self, cls: str = "") -> str:
124
150
  s = self.settings
125
151
  label = s.brand_organization or re.sub(r"^\w+:(//)?", "", s.brand_url).rstrip("/")
@@ -139,6 +165,15 @@ def _link(label: str, url: str, cls: str = "") -> str:
139
165
  return f'<a{_class(cls)} href="{escape(url)}"{new_tab}>{escape(label)}</a>'
140
166
 
141
167
 
168
+ def _inline(text: str) -> str:
169
+ """Plain text, with each [label](url) in it made a link."""
170
+ out, last = [], 0
171
+ for m in FOOTER_LINK.finditer(text):
172
+ out += [escape(text[last : m.start()]), _link(m.group(1), m.group(2))]
173
+ last = m.end()
174
+ return "".join([*out, escape(text[last:])])
175
+
176
+
142
177
  def _fill(page: str, slot: str, html: str) -> str:
143
178
  """Put ``html`` in a slot: ``<!--t2s:slot-->`` or ``<!--t2s:slot-->…<!--/t2s:slot-->``."""
144
179
  pattern = re.compile(rf"<!--t2s:{slot}-->(?:.*?<!--/t2s:{slot}-->)?", re.S)
@@ -0,0 +1 @@
1
+ :root,[data-theme=light]{color-scheme:light;--page: #f3f2ee;--surface: #fdfdfc;--surface-2: #f6f5f1;--ink: #0e1013;--ink-2: #4a4f57;--muted: #646972;--grid: #e7e6e0;--axis: #c7c6be;--border: rgba(14, 16, 19, .08);--shadow: 0 1px 2px rgba(20, 22, 30, .04), 0 12px 32px -16px rgba(20, 22, 40, .18);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--good: #0ca30c;--warning: #fab219;--serious: #ec835a;--critical: #d03b3b;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--on-brand: #ffffff;--radius: 18px;--radius-sm: 10px;--radius-pill: 999px;--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-display: var(--font);--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;--ease: cubic-bezier(.2, .7, .2, 1);--notice: #0e1013;--notice-ink: #f3f2ee}@media(prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--page: #090b0f;--surface: #121419;--surface-2: #0e1015;--ink: #eef0f3;--ink-2: #b4b9c2;--muted: #8a909a;--grid: #22252c;--axis: #353943;--border: rgba(255, 255, 255, .07);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 20px 40px -24px rgba(0, 0, 0, .8);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--notice: #1b1e25;--notice-ink: #d4d8df}}[data-theme=dark]{color-scheme:dark;--page: #090b0f;--surface: #121419;--surface-2: #0e1015;--ink: #eef0f3;--ink-2: #b4b9c2;--muted: #8a909a;--grid: #22252c;--axis: #353943;--border: rgba(255, 255, 255, .07);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 20px 40px -24px rgba(0, 0, 0, .8);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--notice: #1b1e25;--notice-ink: #d4d8df}[data-theme=federal]{color-scheme:light;--page: #eef1f6;--surface: #ffffff;--surface-2: #f4f6f9;--ink: #0d132d;--ink-2: #39415a;--muted: #646d82;--grid: #dfe4ec;--axis: #b6bfcf;--border: rgba(13, 19, 45, .13);--shadow: 0 1px 2px rgba(13, 19, 45, .06), 0 10px 26px -18px rgba(13, 19, 45, .35);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #2f5bd0;--sun-2: #ffbd00;--sun-3: #0d132d;--brand: linear-gradient(140deg, #2a4fb3, #141f4d 62%, #0d132d);--mark-1: #2f5bd0;--mark-2: #1a2d74;--mark-3: #0d132d;--mark-star: #ffbd00;--on-brand: #ffffff;--radius: 6px;--radius-sm: 4px;--radius-pill: 4px;--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, Cambria, "Times New Roman", serif;--notice: #060a1a;--notice-ink: #d9dee8}[data-theme=texas]{color-scheme:dark;--page: #070b15;--surface: #0e1424;--surface-2: #0a101d;--ink: #f3f4f7;--ink-2: #bcc3d1;--muted: #8d95a8;--grid: #1d2537;--axis: #2f3a52;--border: rgba(255, 255, 255, .08);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 22px 44px -26px rgba(0, 0, 0, .9);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #bf0a30;--sun-2: #f05a28;--sun-3: #f6b73c;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: #002868;--mark-2: #4b1f5c;--mark-3: #bf0a30;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 12px;--radius-sm: 7px;--radius-pill: 8px;--font-display: var(--font);--notice: #0b1a3a;--notice-ink: #d2d8e4}[data-theme=america]{color-scheme:light;--page: #f2f5fa;--surface: #ffffff;--surface-2: #eef2f8;--ink: #0a1630;--ink-2: #33405c;--muted: #5f6b86;--grid: #e0e5ee;--axis: #bcc6d8;--border: rgba(10, 49, 97, .15);--shadow: 0 1px 2px rgba(10, 22, 48, .05), 0 12px 30px -18px rgba(10, 22, 48, .3);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #b31942;--sun-2: #1d4c95;--sun-3: #0a3161;--brand: linear-gradient(125deg, #c8203f, #b31942 16%, #0a3161 58%, #163f80);--mark-1: #0a3161;--mark-2: #0a3161;--mark-3: #b31942;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 10px;--radius-sm: 6px;--radius-pill: 6px;--font-display: var(--font);--notice: #0a3161;--notice-ink: #ffffff}[data-theme=energy]{color-scheme:dark;--page: #04080f;--surface: #0a121f;--surface-2: #070d18;--ink: #eaf2fa;--ink-2: #aabdd0;--muted: #8195aa;--grid: #18243a;--axis: #2b3c58;--border: rgba(122, 200, 255, .11);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 22px 44px -26px rgba(0, 0, 0, .9);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #1fd2ff;--sun-2: #1fb8ff;--sun-3: #8f5cff;--brand: linear-gradient(120deg, #0fb5e6, #1f5fe0 50%, #7440f0);--mark-1: #1fd2ff;--mark-2: #1f5fe0;--mark-3: #7440f0;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 8px;--radius-sm: 5px;--radius-pill: 6px;--font-display: var(--font);--notice: #06213a;--notice-ink: #c4e9ff}[data-theme=military]{color-scheme:light;--page: #e9e5d6;--surface: #faf8f1;--surface-2: #f2efe3;--ink: #1b1f14;--ink-2: #454a38;--muted: #5a5e4a;--grid: #d9d4c1;--axis: #b6b09a;--border: rgba(40, 45, 25, .16);--shadow: 0 1px 2px rgba(30, 34, 20, .07), 0 10px 26px -18px rgba(30, 34, 20, .4);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #b09656;--sun-2: #56652b;--sun-3: #262c1b;--brand: linear-gradient(135deg, #6f7d3c, #4b5320 50%, #2c3320);--mark-1: #7d8c47;--mark-2: #4b5320;--mark-3: #2c3320;--mark-star: #ece3c4;--on-brand: #ffffff;--radius: 4px;--radius-sm: 3px;--radius-pill: 3px;--font-display: "DIN Condensed", Bahnschrift, "Ubuntu Condensed", "Roboto Condensed", "Arial Narrow", "DejaVu Sans Condensed", var(--font);--notice: #262c1b;--notice-ink: #ebe5cf}*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{zoom:var(--ui-zoom, 1);margin:0;min-height:100vh;background:var(--page);color:var(--ink);font:15px/1.5 var(--font);-webkit-font-smoothing:antialiased;display:flex;flex-direction:column}a{color:inherit}h1,h2,h3{margin:0;letter-spacing:-.015em;font-family:var(--font-display)}button{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--dl);outline-offset:2px;border-radius:6px}.backdrop{position:fixed;inset:0;z-index:-1;pointer-events:none;background:radial-gradient(60vw 42vw at 8% -12%,color-mix(in srgb,var(--sun-1) 16%,transparent),transparent 70%),radial-gradient(50vw 36vw at 62% -18%,color-mix(in srgb,var(--sun-2) 11%,transparent),transparent 70%),radial-gradient(46vw 40vw at 100% -6%,color-mix(in srgb,var(--sun-3) 12%,transparent),transparent 70%),var(--page)}.backdrop:after{content:"";position:absolute;inset:0;opacity:.05;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}.topbar{position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:10px 24px;padding:12px max(16px,calc((100vw - 1240px)/2));-webkit-backdrop-filter:saturate(1.4) blur(14px);backdrop-filter:saturate(1.4) blur(14px);background:color-mix(in srgb,var(--page) 72%,transparent);border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:10px;text-decoration:none;font-weight:650;font-size:18px;letter-spacing:-.02em}.brand-mark{width:30px;height:30px;filter:drop-shadow(0 4px 10px color-mix(in srgb,var(--sun-2) 35%,transparent))}.brand-word em{font-style:normal;background:var(--brand);-webkit-background-clip:text;background-clip:text;color:transparent;padding:0 1px}.tabs{display:flex;gap:4px}.tabs a{position:relative;padding:8px 12px;border-radius:var(--radius-pill);text-decoration:none;color:var(--ink-2);font-weight:550;font-size:14px;transition:color .2s,background .2s}.tabs a:hover{color:var(--ink);background:color-mix(in srgb,var(--ink) 5%,transparent)}.tabs a[aria-current]{color:var(--ink);background:var(--surface);box-shadow:0 0 0 1px var(--border),0 4px 12px -6px #0003}.topbar-actions{margin-left:auto;display:flex;align-items:center;gap:10px}.picker{display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:var(--radius-pill);background:var(--surface);box-shadow:0 0 0 1px var(--border);font-size:13px;max-width:320px}.picker-status{width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 0 3px color-mix(in srgb,var(--good) 22%,transparent);flex:none}.picker-status.down{background:var(--critical);box-shadow:0 0 0 3px color-mix(in srgb,var(--critical) 22%,transparent)}.picker-name{font-weight:600;white-space:nowrap}.picker-loc{color:var(--muted);white-space:nowrap}.picker-select{border:0;background:transparent;color:var(--ink);font:inherit;font-weight:600;max-width:260px;cursor:pointer}.picker-select option{background:var(--surface);color:var(--ink)}.icon-btn{width:36px;height:36px;display:grid;place-items:center;border:0;border-radius:50%;background:var(--surface);box-shadow:0 0 0 1px var(--border);cursor:pointer}.icon-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-btn svg path{fill:currentColor;fill-opacity:.12}.icon-btn svg circle{fill:currentColor;stroke:none}#view{width:100%;max-width:1240px;margin:0 auto;padding:20px 16px 36px;flex:1;outline:none}.footer{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:24px 16px 32px;color:var(--muted);font-size:13px}.footer a{color:var(--ink-2)}.own-footer{flex:0 0 100%}.own-footer-body{max-width:1208px;margin:0 auto 12px;padding:20px 0 22px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px 56px;text-align:left;line-height:1.6}.own-footer-body.solo{grid-template-columns:minmax(0,1fr);justify-items:center;text-align:center}.own-footer-text p{max-width:80ch;margin:0 0 8px}.own-footer-text p:last-child{margin-bottom:0}.own-footer-links ul{display:grid;gap:6px;margin:0;padding:0;list-style:none}@media(max-width:640px){.own-footer-body{grid-template-columns:minmax(0,1fr)}}.bm-1{stop-color:var(--mark-1)}.bm-2{stop-color:var(--mark-2)}.bm-3{stop-color:var(--mark-3)}.bm-star{fill:var(--mark-star)}img.brand-mark{width:auto;max-width:180px;object-fit:contain;filter:none}.brand-org{margin-left:-10px;padding-left:14px;border-left:1px solid var(--border);color:var(--ink-2);font-size:13px;font-weight:550;white-space:nowrap;text-decoration:none}a.brand-org:hover,.topbar-links a:hover{color:var(--ink);text-decoration:underline;text-underline-offset:3px}.topbar-links{display:flex;gap:16px;font-size:13px;font-weight:550}.topbar-links a{color:var(--ink-2);text-decoration:none;white-space:nowrap}.notice{padding:7px max(16px,calc((100vw - 1240px)/2));background:var(--notice);color:var(--notice-ink);font-size:13px;line-height:1.45;text-align:center}.theme-menu{position:relative}.size-picker,.family-picker{padding:6px 6px 6px 12px}.picker-glyph{color:var(--muted);font-size:12px;font-weight:700;letter-spacing:-.02em}.theme-pop{position:absolute;top:calc(100% + 8px);right:0;z-index:30;min-width:228px;padding:6px;background:var(--surface);color:var(--ink);border-radius:var(--radius-sm);box-shadow:0 0 0 1px var(--border),0 22px 44px -18px #00000073}.theme-pop[hidden]{display:none}.theme-pop-title{padding:6px 10px;color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.08em;text-transform:uppercase}.theme-item{display:flex;align-items:center;gap:12px;width:100%;padding:7px 10px;border:0;border-radius:calc(var(--radius-sm) - 2px);background:none;font-size:14px;text-align:left;cursor:pointer}.theme-item:hover,.theme-item:focus-visible{background:color-mix(in srgb,var(--ink) 7%,transparent);outline:none}.theme-item[aria-checked=true]{font-weight:650}.theme-check{width:16px;height:16px;margin-left:auto;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;opacity:0}.theme-item[aria-checked=true] .theme-check{opacity:1}.theme-swatch{position:relative;flex:none;width:36px;height:24px;border-radius:5px;background:var(--page);box-shadow:0 0 0 1px color-mix(in srgb,var(--ink) 18%,transparent);overflow:hidden}.theme-swatch:before{content:"";position:absolute;top:4px;left:4px;width:15px;height:8px;border-radius:2px;background:var(--surface);box-shadow:0 0 0 1px var(--border)}.theme-swatch:after{content:"";position:absolute;right:4px;bottom:4px;left:4px;height:5px;border-radius:3px;background:var(--brand)}.theme-swatch.split{display:grid;grid-template-columns:1fr 1fr}.theme-swatch.split:before,.theme-swatch.split:after{display:none}.theme-swatch.split>span{background:var(--page)}[data-theme=federal] .topbar{--surface: #182046;--ink: #ffffff;--ink-2: #c9d0e0;--muted: #9ba4bc;--border: rgba(255, 255, 255, .16);color-scheme:dark;background:#0d132d;color:var(--ink);border-bottom:3px solid var(--sun-2);-webkit-backdrop-filter:none;backdrop-filter:none}[data-theme=federal] .tabs a[aria-current]{background:transparent;box-shadow:inset 0 -2px 0 var(--sun-2)}[data-theme=federal] .brand-word em{background:none;color:var(--sun-2)}[data-theme=federal] .backdrop{background:linear-gradient(180deg,#dde3ee,var(--page) 520px)}[data-theme=texas] .topbar:after,[data-theme=energy] .topbar:after{content:"";position:absolute;right:0;left:0;pointer-events:none}[data-theme=texas] .topbar:after{bottom:-1px;height:2px;background:linear-gradient(90deg,#002868 0 33.3%,#fff 33.3% 66.6%,#bf0a30 66.6%)}[data-theme=texas] .tabs a[aria-current],[data-theme=america] .tabs a[aria-current],[data-theme=energy] .tabs a[aria-current]{box-shadow:0 0 0 1px var(--border),inset 0 -2px 0 var(--sun-1)}[data-theme=texas] .backdrop{background:radial-gradient(80vw 40vw at 88% -14%,rgba(0,40,104,.6),transparent 70%),radial-gradient(100vw 32vw at 50% 118%,color-mix(in srgb,var(--sun-2) 22%,transparent),transparent 72%),radial-gradient(60vw 26vw at 8% 112%,color-mix(in srgb,var(--sun-1) 20%,transparent),transparent 70%),var(--page)}[data-theme=america] .topbar{--ink-2: var(--ink);background:linear-gradient(105deg,color-mix(in srgb,#5b85c9 90%,transparent),color-mix(in srgb,#a6bde3 90%,transparent))}[data-theme=america] body:before{--edge: clamp(6px, (100vw - 1240px) / 2 - 10px, 12px);content:"";position:fixed;inset:0;z-index:30;pointer-events:none;background:linear-gradient(#0a3161,#0a3161) top / 100% 4px no-repeat,linear-gradient(#8f1435,#8f1435) left / var(--edge) 100% no-repeat,linear-gradient(#8f1435,#8f1435) right / var(--edge) 100% no-repeat}[data-theme=america] .backdrop{background:radial-gradient(55vw 34vw at 0% -12%,color-mix(in srgb,#0a3161 18%,transparent),transparent 70%),radial-gradient(55vw 34vw at 100% -12%,color-mix(in srgb,#1d4c95 12%,transparent),transparent 70%),var(--page)}[data-theme=america] .backdrop:before{content:"";position:absolute;inset:0;background:radial-gradient(circle,color-mix(in srgb,#0a3161 34%,transparent) 1.2px,transparent 1.8px) 0 0 / 24px 24px;-webkit-mask-image:radial-gradient(58vw 32vw at 0% 0%,#000,transparent 72%);mask-image:radial-gradient(58vw 32vw at 0% 0%,#000,transparent 72%)}[data-theme=energy] .topbar{background:color-mix(in srgb,#02050b 80%,transparent)}[data-theme=energy] .topbar:after{bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--sun-1) 25%,var(--sun-3) 75%,transparent);box-shadow:0 0 10px 1px color-mix(in srgb,var(--sun-1) 45%,transparent)}[data-theme=energy] .backdrop{background:radial-gradient(70vw 36vw at 30% -16%,color-mix(in srgb,var(--sun-1) 13%,transparent),transparent 70%),radial-gradient(56vw 34vw at 100% 112%,color-mix(in srgb,var(--sun-3) 15%,transparent),transparent 70%),var(--page)}[data-theme=energy] .backdrop:before{content:"";position:absolute;inset:0;background:linear-gradient(color-mix(in srgb,var(--sun-1) 9%,transparent) 1px,transparent 1px) 0 0 / 36px 36px,linear-gradient(90deg,color-mix(in srgb,var(--sun-1) 9%,transparent) 1px,transparent 1px) 0 0 / 36px 36px;-webkit-mask-image:radial-gradient(90vw 46vw at 30% 0%,#000,transparent 75%);mask-image:radial-gradient(90vw 46vw at 30% 0%,#000,transparent 75%)}[data-theme=military] .topbar{--surface: #353c26;--ink: #f3efe0;--ink-2: #d2ccb4;--muted: #a9a68c;--border: rgba(243, 239, 224, .16);color-scheme:dark;background:#232918;color:var(--ink);border-bottom:3px solid var(--sun-1);-webkit-backdrop-filter:none;backdrop-filter:none}[data-theme=military] .tabs a{font-size:13px;letter-spacing:.08em;text-transform:uppercase}[data-theme=military] .tabs a[aria-current]{background:transparent;box-shadow:inset 0 -2px 0 var(--sun-1)}[data-theme=military] .brand-word em{background:none;color:var(--sun-1)}[data-theme=military] :is(h1,h2,h3){font-stretch:condensed;letter-spacing:.01em;text-transform:uppercase}[data-theme=military] .backdrop{background:radial-gradient(64vw 40vw at 0% -10%,color-mix(in srgb,var(--sun-2) 16%,transparent),transparent 70%),var(--page)}[data-theme=military] .backdrop:before{content:"";position:absolute;inset:0;opacity:.12;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='720'%3E%3Cfilter id='t' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.004' numOctaves='2' seed='4' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .29 0 0 0 0 .33 0 0 0 0 .13 2.2 0 0 0 -0.6'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='table' tableValues='0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23t)'/%3E%3C/svg%3E") 0 0 / 720px 720px}.card{background:var(--surface);border-radius:var(--radius);box-shadow:0 0 0 1px var(--border),var(--shadow);padding:18px 20px}.banner{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;margin-bottom:20px;padding:12px 16px;border-radius:var(--radius-sm);background:color-mix(in srgb,var(--dl) 9%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--dl) 25%,transparent);font-size:14px}.banner[hidden]{display:none}.hero{display:grid;grid-template-columns:minmax(280px,400px) 1fr;gap:32px;align-items:center;margin-bottom:18px}.hero-head h1{font-size:clamp(24px,3vw,34px);line-height:1.12;font-weight:680;letter-spacing:-.03em;max-width:18em}.status{margin:10px 0 0;color:var(--ink-2);min-height:1.5em}.gauge{position:relative;width:100%;max-width:400px;aspect-ratio:1;margin:0 auto;--level: 0}.gauge-svg{width:100%;height:100%;overflow:visible}.gauge-ring{fill:none;stroke:var(--border)}.gauge-ring.ring-1{fill:color-mix(in srgb,var(--surface) 70%,transparent);stroke:var(--border)}.gauge-ring.ring-2{stroke-dasharray:2 6;stroke:var(--axis);opacity:.5;transform-origin:200px 200px}.gauge-ring.ring-3{stroke:var(--grid)}.gauge.running .ring-2{animation:spin 24s linear infinite;opacity:.9}.gauge-track{fill:none;stroke:var(--grid);stroke-width:14;stroke-linecap:round}.gauge-value{fill:none;stroke:url(#g-latency);stroke-width:14;stroke-linecap:round}.gauge-glow{fill:none;stroke:url(#g-latency);stroke-width:26;stroke-linecap:round;filter:url(#g-blur);opacity:calc(.25 + var(--level) * .55)}.gauge[data-tone=download] :is(.gauge-value,.gauge-glow){stroke:url(#g-download)}.gauge[data-tone=upload] :is(.gauge-value,.gauge-glow){stroke:url(#g-upload)}.gauge-tip{fill:var(--surface);stroke:var(--idle);stroke-width:3;opacity:0;transition:opacity .3s}.gauge[data-tone=download] .gauge-tip{stroke:var(--dl);opacity:1}.gauge[data-tone=upload] .gauge-tip{stroke:var(--ul);opacity:1}.gauge-ticks line{stroke:var(--axis);stroke-linecap:round}.gauge-ticks line.major{stroke:var(--muted);stroke-width:1.5}.gauge-label{fill:var(--muted);font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:middle}.gauge-readout{position:absolute;inset:30% 18% 26%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;opacity:0;transform:scale(.96);transition:opacity .35s var(--ease),transform .35s var(--ease);pointer-events:none}.gauge.running .gauge-readout{opacity:1;transform:none}.gauge-caption{color:var(--ink-2);font-size:13px;font-weight:600;letter-spacing:.02em}.gauge-number{font-size:clamp(44px,7vw,68px);font-weight:650;letter-spacing:-.04em;line-height:1.05}.gauge-unit{color:var(--muted);font-size:14px;font-weight:600}.start-btn{position:absolute;left:50%;top:50%;width:46%;aspect-ratio:1;translate:-50% -50%;border:0;border-radius:50%;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:var(--on-brand);background:var(--brand);box-shadow:0 18px 50px -12px color-mix(in srgb,var(--sun-2) 55%,transparent),inset 0 1px #ffffff59;transition:transform .25s var(--ease),opacity .3s,box-shadow .25s}.start-btn:before{content:"";position:absolute;inset:-10px;border-radius:50%;border:2px solid color-mix(in srgb,var(--sun-2) 45%,transparent);animation:pulse 2.6s var(--ease) infinite}.start-btn:hover{transform:scale(1.03)}.start-btn:active{transform:scale(.98)}.start-label{font-size:clamp(26px,4vw,34px);font-weight:700;letter-spacing:-.02em}.start-sub{font-size:12px;font-weight:550;opacity:.85}.gauge.running .start-btn{top:79%;width:54px;font-size:0;background:var(--surface);color:var(--ink-2);box-shadow:0 0 0 1px var(--border),var(--shadow)}.gauge.running .start-btn:before{display:none}.gauge.running .start-btn:after{content:"";width:14px;height:14px;border-radius:3px;background:currentColor}.gauge.running .start-label,.gauge.running .start-sub{display:none}@keyframes pulse{0%{transform:scale(.94);opacity:.9}70%,to{transform:scale(1.18);opacity:0}}@keyframes spin{to{transform:rotate(360deg)}}.rail{list-style:none;margin:22px 0 18px;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.rail-step{position:relative;display:grid;grid-template-columns:auto 1fr;grid-template-rows:auto auto;column-gap:8px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--surface) 70%,transparent);box-shadow:0 0 0 1px var(--border);overflow:hidden}.rail-dot{grid-row:span 2;align-self:center;width:10px;height:10px;border-radius:50%;background:var(--grid);box-shadow:0 0 0 3px transparent;transition:background .3s,box-shadow .3s}.rail-name{font-size:13px;font-weight:600}.rail-value{font-size:12px;color:var(--ink-2);min-height:1.4em;white-space:nowrap}.rail-step[data-state=active] .rail-dot{background:var(--sun-2);box-shadow:0 0 0 4px color-mix(in srgb,var(--sun-2) 25%,transparent);animation:blink 1.2s ease-in-out infinite}.rail-step[data-state=active]:after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--brand);animation:sweep 1.6s linear infinite;transform-origin:left}.rail-step[data-state=done] .rail-dot{position:relative;background:none}.rail-step[data-state=done] .rail-dot:after{content:"";position:absolute;left:3px;top:-1px;width:4px;height:9px;border:solid var(--good);border-width:0 2px 2px 0;transform:rotate(45deg)}.rail-step[data-state=skipped]{opacity:.5}@keyframes blink{50%{opacity:.45}}@keyframes sweep{0%{transform:scaleX(0)}to{transform:scaleX(1)}}.hero-set{min-width:0;margin:14px 0 0;padding:2px 10px 10px;border:1px solid var(--border);border-radius:var(--radius)}.hero-set+.hero-set{margin-top:10px}.hero-set legend{padding:0 6px;color:var(--muted);font-size:11.5px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.hero-set .rail{margin:0}.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.tiles.six{grid-template-columns:repeat(6,1fr);margin-bottom:20px}.tiles[hidden]{display:none}.tile{padding:11px 15px;border-radius:14px;background:var(--surface);box-shadow:0 0 0 1px var(--border),var(--shadow);min-width:0}.tile-label{display:flex;align-items:center;gap:7px;color:var(--ink-2);font-size:13px;font-weight:550}.tile-figure{display:flex;align-items:baseline;gap:6px;margin-top:4px}.tile-value{font-size:clamp(24px,2.6vw,32px);font-weight:650;letter-spacing:-.03em;line-height:1.1;white-space:nowrap}.tile.compact .tile-value{font-size:22px}.tile-count{align-self:center;color:var(--muted);font-size:14px;font-weight:650;font-variant-numeric:tabular-nums;white-space:nowrap}.tile.compact .tile-figure{flex-wrap:wrap;row-gap:0}.tile-count.bad{color:var(--critical)}.tile-unit{color:var(--muted);font-size:13px;font-weight:600}.tile-sub{margin-top:4px;color:var(--muted);font-size:12px;min-height:1.5em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.grade{display:inline-flex;align-items:center;padding:0 7px;border-radius:var(--radius-pill);font-weight:650;color:var(--ink);background:color-mix(in srgb,var(--good) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--good) 45%,transparent)}.grade[data-grade=B]{background:color-mix(in srgb,var(--warning) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warning) 55%,transparent)}.grade[data-grade=C],.grade[data-grade=D]{background:color-mix(in srgb,var(--serious) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--serious) 55%,transparent)}.grade[data-grade=F]{background:color-mix(in srgb,var(--critical) 20%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--critical) 55%,transparent)}.grade[data-rating=fair]{background:color-mix(in srgb,var(--warning) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warning) 55%,transparent)}.grade[data-rating=poor]{background:color-mix(in srgb,var(--serious) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--serious) 55%,transparent)}.grade[data-rating=bad]{background:color-mix(in srgb,var(--critical) 20%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--critical) 55%,transparent)}.options{margin-top:14px}.options summary{cursor:pointer;color:var(--ink-2);font-size:13px;font-weight:600;width:fit-content;list-style:none;display:flex;align-items:center;gap:6px}.options summary::-webkit-details-marker{display:none}.options summary:before{content:"";width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:rotate(-45deg);transition:transform .2s}.options[open] summary:before{transform:rotate(45deg)}.opt-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 20px;margin-top:12px}.opt{display:flex;flex-direction:column;gap:6px}.opt-label{font-size:12px;color:var(--muted);font-weight:600}.seg{display:inline-flex;padding:3px;border-radius:10px;background:color-mix(in srgb,var(--ink) 5%,transparent);width:fit-content;flex-wrap:wrap}.seg button{border:0;background:transparent;padding:5px 11px;border-radius:7px;font-size:13px;font-weight:550;color:var(--ink-2);cursor:pointer}.seg button[aria-checked=true]{background:var(--surface);color:var(--ink);box-shadow:0 0 0 1px var(--border),0 2px 6px -2px #0003}.link{display:grid;grid-template-columns:minmax(120px,300px) 1fr minmax(120px,300px);align-items:center;gap:16px;padding:18px 22px;margin-bottom:24px}.node{min-width:0}.node-server{text-align:right}.node-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:12px;background:var(--surface-2);box-shadow:0 0 0 1px var(--border);color:var(--ink-2);margin-bottom:8px}.node-server .node-icon{margin-left:auto}.node-icon svg{width:22px;height:22px}.node-title{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-detail{color:var(--muted);font-size:12.5px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.link-wire{position:relative;height:96px}.link-canvas{width:100%;height:100%;display:block}.lane-label{position:absolute;font-size:11px;color:var(--muted);left:50%;translate:-50% 0;white-space:nowrap}.lane-down{top:2px}.lane-up{bottom:2px}.link-center{position:absolute;inset:0;display:grid;place-items:center;font-weight:650;font-size:14px;pointer-events:none;text-shadow:0 0 12px var(--surface),0 0 4px var(--surface)}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.span-2{grid-column:span 2}.card-head{margin-bottom:10px}.card-head h2{font-size:17px;font-weight:650}.card-head p{margin:4px 0 0;color:var(--muted);font-size:13px;max-width:70ch}.chart-plot{width:100%;min-height:60px}.chart-svg{display:block;overflow:visible;font-family:var(--font)}.chart-svg .grid{stroke:var(--grid);stroke-width:1;shape-rendering:crispEdges}.chart-svg .baseline{stroke:var(--axis);stroke-width:1;shape-rendering:crispEdges}.chart-svg .tick{fill:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}.chart-svg .row-label{fill:var(--ink-2);font-size:12.5px;font-weight:550}.chart-svg .direct-label,.chart-svg .bar-label{fill:var(--ink);font-size:12px;font-weight:600;paint-order:stroke;stroke:var(--surface);stroke-width:3px;stroke-linejoin:round}.chart-svg .bar-label{fill:var(--ink-2);font-weight:550}.chart-svg .annot,.chart-svg .empty{fill:var(--muted);font-size:12px}.chart-svg .warmup{fill:color-mix(in srgb,var(--ink) 3.5%,transparent)}.chart-svg .area{opacity:.1}.chart-svg .line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.chart-svg .line.dashed{stroke-dasharray:5 4}.chart-svg .ref{stroke-width:1;opacity:.75}.chart-svg .dot{stroke:var(--surface);stroke-width:2}.chart-svg .iqr{opacity:.14}.chart-svg .median{stroke-width:2.5;stroke-linecap:round}.chart-svg .placeholder{stroke:var(--grid);stroke-dasharray:none}.chart-svg .crosshair{stroke:var(--axis);stroke-width:1}.chart-svg .hit{fill:transparent}.chart-svg .bar:hover{filter:brightness(1.12)}.legend{display:flex;flex-wrap:wrap;gap:4px 16px;margin-bottom:10px;font-size:12.5px;color:var(--ink-2)}.legend-item{display:inline-flex;align-items:center;gap:6px}.legend-key{display:inline-block;flex:none;background:var(--key)}.legend-key.line{width:14px;height:2.5px;border-radius:2px}.legend-key.dash{width:14px;height:2.5px;background:repeating-linear-gradient(90deg,var(--key) 0 4px,transparent 4px 7px)}.legend-key.square{width:10px;height:10px;border-radius:3px}.legend-key.dot{width:8px;height:8px;border-radius:50%}.fairness{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:10px;font-size:12.5px;color:var(--ink-2)}.fair-item{display:inline-flex;align-items:center;gap:6px}.fair-item strong{color:var(--ink)}.packet-body{display:grid;grid-template-columns:minmax(0,1fr);gap:16px}.packet-canvas{display:block;max-width:100%}.packet-scale{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:10px;font-size:12px;color:var(--ink-2)}.scale-bar{width:90px;height:8px;border-radius:4px}.scale-gap{width:10px}.stats-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 18px;margin:0;font-size:13px}.stats-list dt{color:var(--muted);grid-column:1}.stats-list dd{margin:0;grid-column:2;font-weight:600;font-variant-numeric:tabular-nums}.chart-note{margin:10px 0 0;color:var(--ink-2);font-size:12.5px}.chart-note[hidden]{display:none}.stats-list .note{grid-column:1 / -1;margin:8px 0 0;color:var(--ink-2);font-size:12.5px}.readiness{list-style:none;margin:0;padding:0;display:grid;gap:8px}.ready-row{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:10px 12px;border-radius:12px;background:var(--surface-2);box-shadow:0 0 0 1px var(--border)}.ready-text{display:flex;flex-direction:column;min-width:0}.ready-text strong{font-size:14px}.ready-text span{color:var(--muted);font-size:12.5px}.glyph{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.glyph.use{color:var(--ink-2)}.glyph.lvl{width:14px;height:14px;stroke-width:2.6}.level{display:inline-flex;align-items:center;gap:5px;padding:3px 10px 3px 7px;border-radius:var(--radius-pill);font-size:12.5px;font-weight:650;--tone: var(--muted);background:color-mix(in srgb,var(--tone) 16%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--tone) 45%,transparent)}.level .glyph{color:var(--tone)}.level[data-level=great],.level[data-level=good]{--tone: var(--good)}.level[data-level=fair]{--tone: var(--warning)}.level[data-level=poor]{--tone: var(--critical)}.empty-note{color:var(--muted);font-size:13px}.table{width:100%;border-collapse:collapse;font-size:13.5px}.table th,.table td{padding:9px 10px;text-align:left;border-bottom:1px solid var(--grid);vertical-align:top}.table tbody th{width:190px;color:var(--ink-2);font-weight:550}.details .table td{overflow-wrap:anywhere}.table.data thead th{color:var(--muted);font-size:12px;font-weight:600}.table .num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.table .mono{font-family:var(--mono);font-size:12.5px}.table .cell-sub{display:block;color:var(--muted)}.table .when{white-space:nowrap}.table .addr{overflow-wrap:anywhere}.table tbody tr:hover td{background:color-mix(in srgb,var(--ink) 2.5%,transparent)}.table-wrap{overflow-x:auto}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:0;border-radius:10px;background:var(--surface);box-shadow:0 0 0 1px var(--border),0 2px 6px -3px #0003;font-size:13.5px;font-weight:600;text-decoration:none;cursor:pointer;white-space:nowrap}.btn:hover{background:var(--surface-2)}.btn.primary{color:var(--on-brand);background:var(--brand);box-shadow:0 6px 18px -8px color-mix(in srgb,var(--sun-2) 70%,transparent)}.btn.big{padding:11px 20px;font-size:15px}.btn.small{padding:5px 10px;font-size:13px}.btn:disabled{opacity:.6;cursor:progress}.page-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px}.page-head h1{font-size:clamp(24px,2.6vw,30px);font-weight:680;letter-spacing:-.03em}.lede{margin:4px 0 0;color:var(--ink-2);max-width:88ch}.monitor-notice{margin:6px 0 0;max-width:88ch;color:var(--ink);font-weight:550}.monitor-notice[hidden]{display:none}.head-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.chip{padding:5px 10px;border-radius:var(--radius-pill);font-size:12.5px;font-weight:600;color:var(--ink-2);background:var(--surface);box-shadow:0 0 0 1px var(--border)}.toolbar{display:flex;flex-wrap:wrap;gap:12px 28px;margin-bottom:14px}.live-canvas,.loss-strip{display:block;width:100%}.strip-label{display:flex;align-items:center;gap:6px;margin:10px 0 4px 46px;font-size:12px;color:var(--muted)}.dest-box{display:contents}.options.monitor-sizes{align-self:flex-end;margin:0 0 9px}.options.monitor-sizes[open]{flex-basis:100%;margin-bottom:0}.seg.seg-feature{background:color-mix(in srgb,var(--sun-2) 10%,transparent);box-shadow:0 0 0 1.5px color-mix(in srgb,var(--sun-2) 55%,transparent)}.seg.seg-feature.invite{animation:invite 1.6s var(--ease) .6s 2}@keyframes invite{50%{box-shadow:0 0 0 1.5px color-mix(in srgb,var(--sun-2) 55%,transparent),0 0 0 7px color-mix(in srgb,var(--sun-2) 18%,transparent)}}.seg-dots{display:inline-flex;gap:3px;margin-left:6px;vertical-align:1px}.servers-opt .opt-label{color:var(--ink-2);font-weight:650}.card-head.head-inline{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px}.card-head.head-inline p{margin:0}.card-head.head-inline .chip{margin-left:auto;align-self:center}.loss-strip+.loss-strip{margin-top:4px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.gated .tabs{display:none}.gated .server-picker,.gated #family-picker{visibility:hidden}.gate{display:grid;place-items:center;padding:clamp(16px,8vh,72px) 0}.gate-card{width:min(100%,460px);padding:26px 28px}.gate-card h1{font-size:clamp(22px,2.4vw,26px);font-weight:680;letter-spacing:-.02em}.gate-form{display:flex;gap:10px;margin-top:18px}.gate-form .btn{justify-content:center}.code-input{flex:1;min-width:0;padding:10px 14px;border:0;border-radius:10px;background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--border);color:var(--ink);font:inherit;font-size:17px;letter-spacing:.02em}.code-input:focus{outline:2px solid var(--dl);outline-offset:1px}.code-input.small{flex:none;width:9.5em;padding:5px 10px;font-size:13.5px}.gate-error{min-height:1.4em;margin:10px 0 0;color:var(--ul);font-weight:600}.gate-note{margin:2px 0 0;color:var(--muted);font-size:13.5px}.result-id{align-self:center;margin:0;color:var(--ink-2)}.result-id strong,.desk-code strong{font-family:var(--mono);font-size:1.08em;color:var(--ink);letter-spacing:.02em}.desk-tools{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center}.desk-code{margin:0;color:var(--ink-2)}.desk-open{display:flex;gap:6px}@media(max-width:600px){.gate-card{padding:20px 18px}.gate-form{flex-direction:column}}.diag-card{margin-bottom:20px}.diag-addresses{display:grid;gap:6px}.diag-you{display:flex;flex-wrap:wrap;gap:4px 40px}.diag-addr{display:flex;align-items:baseline;gap:10px;min-width:0}.diag-fam{color:var(--muted);font-size:13px;font-weight:600;white-space:nowrap}.diag-ip{font-family:var(--mono);font-size:clamp(16px,calc((100vw - 170px) / 24),26px);font-weight:650;overflow-wrap:anywhere}.diag-ip.none{font-family:var(--font);font-size:15px;font-weight:500;color:var(--muted)}.diag-server{color:var(--ink-2);font-family:var(--mono);font-size:15px;overflow-wrap:anywhere}.diag-clock{color:var(--ink-2);font-size:13px}.lede.wide,.card-head p.wide{max-width:none}.diag-row{display:grid;grid-template-columns:150px minmax(120px,1fr) minmax(240px,1.5fr) minmax(150px,300px);align-items:center;gap:14px;padding:7px 2px;border-bottom:1px solid var(--grid)}.diag-row:last-child{border-bottom:0}.diag-row[data-tone=off]{opacity:.65}.diag-what{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 8px}.diag-what strong{font-weight:650}.diag-port{color:var(--muted);font-family:var(--mono);font-size:12px}.lane,.diag-timeline{display:block;width:100%}.diag-badge{margin-right:8px;font-size:13.5px;font-weight:650;color:var(--muted)}.diag-row[data-tone=up] .diag-badge{color:var(--good-ink)}.diag-row[data-tone=loss] .diag-badge{color:var(--critical)}.diag-figs{color:var(--ink-2);font-size:12.5px;font-variant-numeric:tabular-nums}.diag-detail{color:var(--ink-2);font-size:12px;overflow-wrap:anywhere}.diag-note{margin:12px 0 0;color:var(--ink-2);font-size:12.5px}.egress{display:grid;gap:14px}.egress-fam h3{margin:0 0 4px;font-size:14px;font-weight:650}.egress-fam dl{display:grid;grid-template-columns:auto 1fr;gap:3px 16px;margin:0;font-size:13px}.egress-fam dt{color:var(--muted)}.egress-fam dd{margin:0;overflow-wrap:anywhere}.egress-note{display:block;color:var(--muted);font-size:12px}.diag-log-head{justify-content:space-between}.diag-log{max-height:260px;margin:0;padding:0;overflow:auto;list-style:none;font-size:12.5px}.diag-log li{display:flex;gap:10px;padding:3px 0;border-bottom:1px solid var(--grid)}.diag-log time{color:var(--muted);font-family:var(--mono);font-variant-numeric:tabular-nums}.diag-log .none{color:var(--muted)}@media(max-width:980px){.diag-row{grid-template-columns:1fr 1.6fr}.diag-row .lane,.diag-timeline{grid-column:1 / -1}}@media(max-width:600px){.diag-row{grid-template-columns:1fr;gap:6px}.diag-addr{flex-direction:column;align-items:flex-start;gap:0}.diag-ip.v6:not(.none){font-size:clamp(11px,calc((100vw - 72px) / 24),18px)}.diag-ip.v4:not(.none){font-size:clamp(18px,calc((100vw - 72px) / 11),24px)}.diag-server{font-size:clamp(10.5px,calc((100vw - 72px) / 25.5),15px)}}.opt-label .legend-key{vertical-align:1px}.dest-select{max-width:260px;padding:6px 10px;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 5%,transparent);color:var(--ink);font:inherit;font-size:13px;font-weight:550;cursor:pointer}.dest-select option{background:var(--surface);color:var(--ink)}.dest-select:disabled,.seg button:disabled{cursor:default;opacity:.6}.compare-wrap{margin-bottom:12px}.table.compare th,.table.compare td{padding:3px 10px}.table.compare thead th{padding-bottom:6px}.table.compare thead th{vertical-align:bottom;font-size:13px;font-weight:650}.table.compare thead th:last-child{color:var(--muted);font-weight:600}.table.compare tbody td{font-size:14px;font-weight:600}.table.compare tbody td:last-child{color:var(--ink-2);font-weight:550}.table.compare td.bad{color:var(--critical)}.compare-name{display:inline-flex;align-items:center;gap:6px}.compare-sub{margin-left:8px;color:var(--muted);font-size:12px;font-weight:500}.empty-card{display:flex;flex-direction:column;align-items:center;gap:12px;padding:48px;color:var(--ink-2)}.chart-card+.card,.card+.card{margin-top:0}.page>.card,.page>div>.card{margin-bottom:20px}.cli-card{margin-bottom:28px}.cli-card h2,.api-group h2{font-size:19px;font-weight:650}.cli-card p{color:var(--ink-2);margin:6px 0 14px}.api-group{margin-bottom:32px}.api-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;margin-top:14px}.api-item{display:flex;flex-direction:column;gap:6px;padding:18px}.api-item h3{font-size:15px}.api-item p{margin:0 0 6px;color:var(--ink-2);font-size:13.5px;flex:1}.api-sig{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:13px;min-width:0}.api-sig code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.method{padding:2px 7px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--ink);background:color-mix(in srgb,var(--idle) 22%,transparent)}.method.m-post{background:color-mix(in srgb,var(--ul) 22%,transparent)}.method.m-ws{background:color-mix(in srgb,var(--dl) 22%,transparent)}.code{position:relative;border-radius:10px;background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--border)}.code pre{margin:0;padding:12px 64px 12px 14px;overflow-x:auto;font:12.5px/1.6 var(--mono);color:var(--ink-2)}.api-item .code pre{white-space:pre-wrap;word-break:break-all}.code .copy{position:absolute;top:8px;right:8px;border:0;padding:3px 8px;border-radius:6px;font-size:12px;background:var(--surface);box-shadow:0 0 0 1px var(--border);cursor:pointer}.tooltip{position:fixed;left:0;top:0;z-index:50;pointer-events:none;min-width:140px;padding:8px 10px;border-radius:10px;background:color-mix(in srgb,var(--surface) 94%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:0 0 0 1px var(--border),0 12px 28px -10px #00000059;font-size:12.5px}.tooltip[hidden]{display:none}.tip-title{color:var(--muted);font-size:11.5px;margin-bottom:4px}.tip-row{display:flex;align-items:center;gap:7px;white-space:nowrap}.tip-row strong{font-variant-numeric:tabular-nums}.tip-row span:last-child{color:var(--ink-2)}.tip-key{flex:none;background:var(--key)}.tip-key.line{width:12px;height:2.5px;border-radius:2px}.tip-key.dot{width:8px;height:8px;border-radius:50%}.tip-key.square{width:9px;height:9px;border-radius:2px}.tip-key.none{width:0}.noscript{text-align:center;padding:40px}@media(max-width:980px){.hero{grid-template-columns:1fr;gap:16px}.hero-gauge{max-width:400px;width:100%;margin:0 auto}.tiles.six{grid-template-columns:repeat(3,1fr)}.tile[data-key=mos] .tile-count,.tile[data-key=worst] .tile-count{flex-basis:100%}}@media(max-width:1180px){.topbar-links{display:none}}.topbar.wrapped{flex-wrap:wrap}@media(max-width:600px){#size-picker{display:none}.table.compare th,.table.compare td{padding:5px 6px;font-size:13px}.table.compare thead th{white-space:normal}.compare-sub{display:none}.table .addr{display:inline-block;width:20ch}.details .table,.details .table tbody,.details .table tr{display:block}.details .table tr{padding:8px 0;border-bottom:1px solid var(--grid)}.details .table th,.details .table td{display:block;width:auto;padding:0;border:0}}@media(max-width:760px){.topbar{flex-wrap:wrap;gap:10px 16px}.brand-org{display:none}.tabs{order:3;width:100%;overflow-x:auto}.topbar-actions{flex:1 1 0;min-width:0;justify-content:flex-end}#server-picker,#server-picker .picker,#server-picker .picker>*{min-width:0}#server-picker .picker-name,#server-picker .picker-select{overflow:hidden;text-overflow:ellipsis}.family-picker .picker-select{field-sizing:content}.grid{grid-template-columns:minmax(0,1fr)}.span-2{grid-column:auto}.tiles,.rail{grid-template-columns:repeat(2,1fr)}.hero-set{margin-top:22px;padding:0;border:0}.hero-set+.hero-set{margin-top:18px}.hero-set legend{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.link{grid-template-columns:1fr 1fr;padding:16px}.link-wire{grid-column:1 / -1;grid-row:2}.node-detail{white-space:normal;overflow-wrap:anywhere}.card{padding:16px}.picker-loc{display:none}.table tbody th{width:auto}.stats-list{grid-template-columns:auto 1fr}}@media(max-width:420px){.tiles.six{grid-template-columns:repeat(2,1fr)}.picker{max-width:180px}.picker-select{max-width:130px}.topbar:has(#family-picker .picker) .brand-word{display:none}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(forced-colors:active){.gauge-value,.chart-svg .line{stroke:CanvasText}}