keepup-http 0.5.0__tar.gz → 0.7.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 (49) hide show
  1. {keepup_http-0.5.0 → keepup_http-0.7.0}/PKG-INFO +1 -1
  2. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/__init__.py +1 -1
  3. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/factory.py +19 -0
  4. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/routes.py +219 -89
  5. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/main_nebula.css +22 -0
  6. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/main_new.css +173 -0
  7. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/index_nebula.html +31 -14
  8. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/index_new.html +31 -14
  9. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/main_new.js +542 -84
  10. keepup_http-0.7.0/keepup_http/static/js/mfa_panel.js +342 -0
  11. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/PKG-INFO +1 -1
  12. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/SOURCES.txt +1 -0
  13. {keepup_http-0.5.0 → keepup_http-0.7.0}/pyproject.toml +1 -1
  14. {keepup_http-0.5.0 → keepup_http-0.7.0}/README.md +0 -0
  15. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/api_docs.py +0 -0
  16. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/body_limit.py +0 -0
  17. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/events_api.py +0 -0
  18. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/lock_routes.py +0 -0
  19. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/plugin.py +0 -0
  20. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/aos.css +0 -0
  21. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/tailwind.css +0 -0
  22. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/aos.js +0 -0
  23. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/feather-icons.js +0 -0
  24. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
  25. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/tailwind.js +0 -0
  26. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
  27. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/cluster.css +0 -0
  28. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/event_manager.css +0 -0
  29. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
  30. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/metrics.css +0 -0
  31. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/modules.css +0 -0
  32. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/themes.css +0 -0
  33. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/users.css +0 -0
  34. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
  35. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chart.js +0 -0
  36. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
  37. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/cluster.js +0 -0
  38. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/event_manager.js +0 -0
  39. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
  40. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/metrics.js +0 -0
  41. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/modules.js +0 -0
  42. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/themes.js +0 -0
  43. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/users.js +0 -0
  44. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/web.py +0 -0
  45. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/dependency_links.txt +0 -0
  46. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/entry_points.txt +0 -0
  47. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/requires.txt +0 -0
  48. {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/top_level.txt +0 -0
  49. {keepup_http-0.5.0 → keepup_http-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-http
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: The way into the application over HTTP: the application, its routes and its middleware.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ route wrapper; they move in one slice at a time, and the base answers their old
10
10
  names through the compatibility layer for one release (keepup-122).
11
11
  """
12
12
 
13
- __version__ = "0.5.0"
13
+ __version__ = "0.7.0"
14
14
 
15
15
  __all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
16
16
  "register_event_api_routes"]
@@ -43,6 +43,7 @@ from keepup_audit.events import init_event_manager
43
43
  from keepup_http.events_api import register_event_api_routes
44
44
  from keepup.instance import get_instance_id
45
45
  from keepup_http.lock_routes import register_lock_routes
46
+ from keepup_http.routes import register_declared_routes, register_declared_sockets
46
47
  from keepup_metrics.api import register_metrics_routes
47
48
  from keepup_modules.catalogue import register_module_routes
48
49
  from keepup.plugins.registry import (
@@ -168,6 +169,11 @@ def apply_settings(settings: KeepupSettings) -> None:
168
169
  favicon_file=settings.favicon_file,
169
170
  )
170
171
  auth_dependencies.configure_panel_gate(pending=settings.pending_documents)
172
+ # The panel's cookies carry the application's name, so several keepup
173
+ # applications on one host do not overwrite each other's session (0.7.0).
174
+ panel_session.configure(
175
+ settings.cookie_prefix if settings.cookie_prefix is not None
176
+ else settings.project_name)
171
177
  if not settings.database_dialect:
172
178
  # The framework's configuration is the default; naming the dialect lets
173
179
  # the kernel choose a driver without importing a database (keepup-123).
@@ -314,6 +320,19 @@ def _build_lifespan(settings: KeepupSettings):
314
320
  if manager is not None:
315
321
  await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
316
322
 
323
+ # What the kernel's plugins declared is bound here (keepup-121): an
324
+ # application that named only `plugins_dir` used to get no route of its
325
+ # own, because only a PluginManager of the 0.3.0 path ever bound them.
326
+ # The manager above registered its declarations a moment ago; a route the
327
+ # application already answers is skipped rather than answered twice.
328
+ if kernel is not None:
329
+ mounted = kernel.mount({
330
+ "routes": lambda items, runtime=None: register_declared_routes(app, items),
331
+ "sockets": lambda items, runtime=None: register_declared_sockets(app, items),
332
+ })
333
+ if mounted:
334
+ logger.info("Mounted what the plugins declared: %s", mounted)
335
+
317
336
  # The log handlers the running capabilities contribute -- the shipping
318
337
  # one, when a collector is named (keepup-118). A deployment that
319
338
  # installs none attaches nothing.
@@ -40,6 +40,8 @@ from keepup.plugins import route_mask
40
40
  __all__ = [
41
41
  "accepted_params",
42
42
  "raw_request_wrapper",
43
+ "register_declared_routes",
44
+ "register_declared_sockets",
43
45
  "register_plugin_routes",
44
46
  ]
45
47
 
@@ -335,10 +337,9 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
335
337
 
336
338
  async def call(request: Request, current_user):
337
339
  """One call of a plugin route, from the audit's first row to its last."""
338
- request_data = {
339
- 'user_id': current_user.get('id') if current_user else None,
340
- 'username': current_user.get('username') if current_user else 'anonymous',
341
- }
340
+ # The actor is not part of request_data: that is redacted whole, and a
341
+ # row that cannot name who called is not an audit (keepup-172).
342
+ request_data = {}
342
343
  if has_path_params:
343
344
  request_data['path_params'] = dict(request.path_params)
344
345
  request_data['query_params'] = dict(request.query_params)
@@ -353,7 +354,8 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
353
354
  method=method_name,
354
355
  endpoint=path,
355
356
  host='plugin_api',
356
- request_data=request_data
357
+ request_data=request_data,
358
+ actor=current_user
357
359
  ) as request_id:
358
360
  try:
359
361
  response = await invoke(request, current_user)
@@ -413,95 +415,223 @@ async def register_plugin_routes(app, manager):
413
415
  logger.info(f"Found {len(routes)} plugin routes to register")
414
416
 
415
417
  for route in routes:
416
- handler = route['handler']
417
- path = route['path']
418
- methods = route['methods']
419
- require_auth = route.get('require_auth', True)
420
- is_upload = route.get('is_upload', False)
421
- permission = route.get('permission')
422
- audit = route.get('audit', True)
423
- response_media_type = route.get('response_media_type')
424
- if response_media_type is not None and (
425
- not isinstance(response_media_type, str) or not response_media_type.strip()):
426
- raise ValueError(
427
- f"{path}: response_media_type is a media type, like 'text/plain'")
428
- if response_media_type is not None and route.get('raw_request'):
429
- raise ValueError(
430
- f"{path}: a raw_request route answers with a Response of its own; "
431
- "response_media_type would be ignored. Drop one of the two.")
432
- if permission is not None:
433
- # A right needs somebody to hold it: on a route nobody signs in to,
434
- # or one that signs itself in, it would be a check that never runs.
435
- if not isinstance(permission, str) or not permission.strip():
436
- raise ValueError(f"{path}: permission must be a non-empty string")
437
- if route.get('raw_request') or not require_auth:
438
- raise ValueError(
439
- f"{path}: a route that declares a permission must be signed in by "
440
- "the framework -- drop require_auth: false or raw_request, or the "
441
- "permission")
442
-
443
- logger.info(
444
- f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
445
-
446
- # The kernel parsed and checked the mask when it collected the route,
447
- # for every transport there will ever be (keepup-102); a route that
448
- # reaches here without one was declared by something else, and is
449
- # checked the same way rather than trusted.
450
- mask = route.get('_mask')
451
- if mask is None:
452
- try:
453
- mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
454
- route_mask.check_signature(handler, mask)
455
- except route_mask.MaskError as error:
456
- # The same loudness as the raw_request check below: a mask naming
457
- # a parameter the handler does not take is a typo, and finding it
458
- # on a request would mean hearing about it from a user.
459
- raise ValueError(f"{path}: {error}")
460
-
461
- if route.get('raw_request'):
462
- # A raw route authenticates inside the handler -- it is handed the
463
- # Request and nothing else. Declaring require_auth on it therefore
464
- # promises a check the framework does not make, and the start-up
465
- # line used to print auth=True over an open route (keepup-12).
466
- if route.get('require_auth') is True:
467
- raise ValueError(
468
- f"{path}: a raw_request route cannot also declare "
469
- "require_auth -- the handler receives the Request and "
470
- "authenticates itself. Drop one of the two.")
471
- require_auth = False
472
- wrapper = raw_request_wrapper(handler)
473
- else:
474
- wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
475
- permission, audit=audit,
476
- response_media_type=response_media_type)
477
-
478
- # What the route says about its body: its own limit, or that it reads
479
- # the body itself (keepup/body_limit.py).
480
- declare_limit(wrapper, route)
481
-
482
- app.add_api_route(
483
- path,
484
- wrapper,
485
- methods=methods,
486
- include_in_schema=route.get('include_in_schema', True)
487
- )
488
-
489
- logger.info(
490
- f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
418
+ _mount_route(app, route)
491
419
 
492
420
  websocket_routes = manager.get_all_websocket_routes()
493
421
  logger.info(f"Found {len(websocket_routes)} WebSocket plugin routes to register")
494
422
 
495
423
  for ws_route in websocket_routes:
496
- path = ws_route['path']
497
- handler = ws_route['handler']
498
- # Off by default, unlike HTTP routes: sockets of agents and guests carry
499
- # tokens of their own and would be refused by the panel's sign-in rule.
500
- if ws_route.get('require_auth', False):
501
- handler = signed_in_websocket(handler)
502
- _register_websocket(app, path, handler)
503
- logger.info(f"Registered WebSocket plugin route: {path} "
504
- f"(auth={bool(ws_route.get('require_auth', False))})")
424
+ _mount_socket(app, ws_route)
425
+
426
+
427
+ def register_declared_routes(app, declarations) -> int:
428
+ """Mount the routes the kernel collected from the plugins (keepup-121).
429
+
430
+ The kernel resolves, loads and initialises the plugins, and what they
431
+ declared is the runtime's, transport-neutral: this is the HTTP half of that
432
+ seam, the call ``runtime.mount`` hands to a registrar. Until it existed an
433
+ application that named only ``plugins_dir`` got no route of its own, because
434
+ only a PluginManager of the 0.3.0 path ever bound them.
435
+
436
+ What the application already answers is left alone: the framework's own
437
+ routes are registered before the kernel starts, and an application that
438
+ passes a manager has its declarations bound by `register_plugin_routes` --
439
+ so one declaration never becomes two endpoints.
440
+
441
+ Args:
442
+ app: the FastAPI application.
443
+ declarations: the route declarations the kernel collected, each tagged
444
+ with ``_plugin_id`` and, because the collector does it once for every
445
+ transport, a parsed ``_mask``.
446
+
447
+ Returns:
448
+ How many declarations were mounted.
449
+ """
450
+ mounted = 0
451
+ for declaration in declarations:
452
+ path = declaration["path"]
453
+ methods = tuple(declaration.get("methods") or ())
454
+ if _already_mounted(app, path, methods):
455
+ logger.info("Route %s %s already answers: the kernel's declaration is "
456
+ "not mounted twice", path, list(methods))
457
+ continue
458
+ _mount_route(app, declaration)
459
+ mounted += 1
460
+ return mounted
461
+
462
+
463
+ def register_declared_sockets(app, declarations) -> int:
464
+ """Mount the sockets the kernel collected from the plugins (keepup-121).
465
+
466
+ The same seam as `register_declared_routes`, for the other thing a plugin
467
+ declares. A socket the application already answers is left alone.
468
+
469
+ Args:
470
+ app: the FastAPI application.
471
+ declarations: the socket declarations the kernel collected.
472
+
473
+ Returns:
474
+ How many declarations were mounted.
475
+ """
476
+ mounted = 0
477
+ for declaration in declarations:
478
+ path = declaration["path"]
479
+ if _already_mounted(app, path):
480
+ logger.info("Socket %s already answers: the kernel's declaration is not "
481
+ "mounted twice", path)
482
+ continue
483
+ _mount_socket(app, declaration)
484
+ mounted += 1
485
+ return mounted
486
+
487
+
488
+ def _mount_route(app, declaration):
489
+ """Turn one declared route into an endpoint, having checked it first.
490
+
491
+ One implementation for every declaration, whatever collected it -- the
492
+ manager of the 0.3.0 path or the kernel's catalogue (keepup-21, keepup-121).
493
+ A declaration the route runtime refuses -- a permission on a route nobody
494
+ signs in to, a mask naming a parameter the handler does not take -- is a
495
+ mistake found at the start, rather than a route that quietly goes missing
496
+ (keepup-102).
497
+
498
+ Args:
499
+ app: the FastAPI application.
500
+ declaration: the route as the plugin declared it. The kernel's collector
501
+ leaves a parsed ``_mask`` on it; a declaration from anywhere else is
502
+ checked here instead of trusted.
503
+
504
+ Raises:
505
+ ValueError: when the declaration is one the runtime refuses.
506
+ """
507
+ route = declaration
508
+ handler = route['handler']
509
+ path = route['path']
510
+ methods = route['methods']
511
+ require_auth = route.get('require_auth', True)
512
+ is_upload = route.get('is_upload', False)
513
+ permission = route.get('permission')
514
+ audit = route.get('audit', True)
515
+ response_media_type = route.get('response_media_type')
516
+ if response_media_type is not None and (
517
+ not isinstance(response_media_type, str) or not response_media_type.strip()):
518
+ raise ValueError(
519
+ f"{path}: response_media_type is a media type, like 'text/plain'")
520
+ if response_media_type is not None and route.get('raw_request'):
521
+ raise ValueError(
522
+ f"{path}: a raw_request route answers with a Response of its own; "
523
+ "response_media_type would be ignored. Drop one of the two.")
524
+ if permission is not None:
525
+ # A right needs somebody to hold it: on a route nobody signs in to,
526
+ # or one that signs itself in, it would be a check that never runs.
527
+ if not isinstance(permission, str) or not permission.strip():
528
+ raise ValueError(f"{path}: permission must be a non-empty string")
529
+ if route.get('raw_request') or not require_auth:
530
+ raise ValueError(
531
+ f"{path}: a route that declares a permission must be signed in by "
532
+ "the framework -- drop require_auth: false or raw_request, or the "
533
+ "permission")
534
+
535
+ logger.info(
536
+ f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
537
+
538
+ # The kernel parsed and checked the mask when it collected the route,
539
+ # for every transport there will ever be (keepup-102); a route that
540
+ # reaches here without one was declared by something else, and is
541
+ # checked the same way rather than trusted.
542
+ mask = route.get('_mask')
543
+ if mask is None:
544
+ try:
545
+ mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
546
+ route_mask.check_signature(handler, mask)
547
+ except route_mask.MaskError as error:
548
+ # The same loudness as the raw_request check below: a mask naming
549
+ # a parameter the handler does not take is a typo, and finding it
550
+ # on a request would mean hearing about it from a user.
551
+ raise ValueError(f"{path}: {error}")
552
+
553
+ if route.get('raw_request'):
554
+ # A raw route authenticates inside the handler -- it is handed the
555
+ # Request and nothing else. Declaring require_auth on it therefore
556
+ # promises a check the framework does not make, and the start-up
557
+ # line used to print auth=True over an open route (keepup-12).
558
+ if route.get('require_auth') is True:
559
+ raise ValueError(
560
+ f"{path}: a raw_request route cannot also declare "
561
+ "require_auth -- the handler receives the Request and "
562
+ "authenticates itself. Drop one of the two.")
563
+ require_auth = False
564
+ wrapper = raw_request_wrapper(handler)
565
+ else:
566
+ wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
567
+ permission, audit=audit,
568
+ response_media_type=response_media_type)
569
+
570
+ # What the route says about its body: its own limit, or that it reads
571
+ # the body itself (keepup/body_limit.py).
572
+ declare_limit(wrapper, route)
573
+
574
+ app.add_api_route(
575
+ path,
576
+ wrapper,
577
+ methods=methods,
578
+ include_in_schema=route.get('include_in_schema', True)
579
+ )
580
+
581
+ logger.info(
582
+ f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
583
+
584
+
585
+ def _mount_socket(app, declaration):
586
+ """Turn one declared socket into a route on the application.
587
+
588
+ The other half of the same seam: the kernel collects what a plugin declared,
589
+ and this is where HTTP binds it. Kept beside `_mount_route` because one
590
+ declaration should have one way in, whatever collected it.
591
+
592
+ Args:
593
+ app: the FastAPI application.
594
+ declaration: the socket as the plugin declared it.
595
+ """
596
+ ws_route = declaration
597
+ path = ws_route['path']
598
+ handler = ws_route['handler']
599
+ # Off by default, unlike HTTP routes: sockets of agents and guests carry
600
+ # tokens of their own and would be refused by the panel's sign-in rule.
601
+ if ws_route.get('require_auth', False):
602
+ handler = signed_in_websocket(handler)
603
+ _register_websocket(app, path, handler)
604
+ logger.info(f"Registered WebSocket plugin route: {path} "
605
+ f"(auth={bool(ws_route.get('require_auth', False))})")
606
+
607
+
608
+ def _already_mounted(app, path, methods=None) -> bool:
609
+ """Whether the application already answers this address, and these verbs.
610
+
611
+ Asked before a kernel-collected declaration is mounted: the framework's own
612
+ routes and the ones a PluginManager bound are registered first, and a path
613
+ registered twice is an application that answers twice. A socket is asked
614
+ about by path alone -- it has no methods of its own.
615
+
616
+ Args:
617
+ app: the FastAPI application.
618
+ path: the address.
619
+ methods: the verbs, or None for a socket.
620
+
621
+ Returns:
622
+ True when the application already has it.
623
+ """
624
+ for route in getattr(app, "routes", ()):
625
+ if getattr(route, "path", None) != path:
626
+ continue
627
+ existing = getattr(route, "methods", None)
628
+ if methods is None:
629
+ if not existing:
630
+ return True
631
+ continue
632
+ if tuple(sorted(existing or ())) == tuple(sorted(methods)):
633
+ return True
634
+ return False
505
635
 
506
636
 
507
637
  def _register_websocket(app, path, handler):
@@ -47,6 +47,28 @@
47
47
  --nav-item-bg-active: rgba(6, 182, 212, 0.24);
48
48
  --nav-item-icon-gap: 0.75rem;
49
49
 
50
+ /* The menu's search (keepup-179). A translucent field on a translucent
51
+ sidebar reads as a hole in it, so this theme gives the field a border of
52
+ its own and the focus ring the accent colour of the buttons. */
53
+ --nav-search-bg: rgba(255, 255, 255, 0.05);
54
+ --nav-search-bg-focus: rgba(6, 182, 212, 0.12);
55
+ --nav-search-border: rgba(148, 163, 184, 0.22);
56
+ --nav-search-border-focus: rgba(6, 182, 212, 0.6);
57
+ --nav-search-radius: 10px;
58
+ --nav-search-fg: var(--nebula-text-dim);
59
+ --nav-search-value: #ffffff;
60
+ --nav-search-placeholder: var(--nebula-text-dim);
61
+ --nav-item-search-base: 1.25rem;
62
+ --nav-item-search-step: 1rem;
63
+
64
+ /* The match and the keyboard's row (keepup-181). The mark borrows the
65
+ theme's cyan rather than the default amber: on this sidebar the amber
66
+ reads as a warning, and the cyan is what a control here looks like. */
67
+ --nav-search-hit-bg: rgba(6, 182, 212, 0.9);
68
+ --nav-search-hit-fg: #04121c;
69
+ --nav-search-current-bg: rgba(6, 182, 212, 0.2);
70
+ --nav-search-current-ring: rgba(6, 182, 212, 0.7);
71
+
50
72
  --modules-nav-max-height: calc(100vh - 200px);
51
73
  --modules-nav-padding: 8px 10px 16px;
52
74
  --sidebar-scrollbar-track: rgba(255, 255, 255, 0.04);
@@ -35,6 +35,29 @@
35
35
  --nav-item-bg-active: #1e40af;
36
36
  --nav-item-icon-gap: 0.75rem;
37
37
 
38
+ /* The search of the menu (keepup-179). A field sits above the entries and
39
+ a match that is a submodule is pushed right by its depth, so a result
40
+ shown without its parent still reads as a sub-item. */
41
+ --nav-search-bg: rgba(255, 255, 255, 0.12);
42
+ --nav-search-bg-focus: rgba(255, 255, 255, 0.2);
43
+ --nav-search-border: rgba(255, 255, 255, 0.16);
44
+ --nav-search-border-focus: rgba(255, 255, 255, 0.5);
45
+ --nav-search-radius: 10px;
46
+ --nav-search-fg: rgba(255, 255, 255, 0.75);
47
+ --nav-search-value: #ffffff;
48
+ --nav-search-placeholder: rgba(255, 255, 255, 0.6);
49
+ --nav-item-search-base: 1.5rem;
50
+ --nav-item-search-step: 1.1rem;
51
+
52
+ /* The match inside a result, and the row the arrow keys are on
53
+ (keepup-181). The mark is a colour pair of its own so it reads on the
54
+ sidebar; the current row is a ring rather than a fill, because hover has
55
+ already claimed the fill. */
56
+ --nav-search-hit-bg: #fde68a;
57
+ --nav-search-hit-fg: #1e3a8a;
58
+ --nav-search-current-bg: rgba(255, 255, 255, 0.2);
59
+ --nav-search-current-ring: rgba(255, 255, 255, 0.55);
60
+
38
61
  /* Adaptive layout. --layout-breakpoint is the single source of the
39
62
  narrow/wide boundary: main_new.js reads it off :root, drives a
40
63
  matchMedia off it and keeps the .is-narrow class on <html>, and the
@@ -224,6 +247,148 @@
224
247
  transition: margin 0.3s ease;
225
248
  }
226
249
 
250
+ /* The field above the menu (keepup-179). Chrome, so it is declared here and a
251
+ section must not restate it. It sits outside #modules-nav-container on
252
+ purpose: the entries scroll under it, the field stays where it was put. */
253
+ .nav-search {
254
+ display: flex;
255
+ align-items: center;
256
+ margin: 0.25rem 0.75rem 0.5rem;
257
+ padding: 0 0.75rem;
258
+ border: 1px solid var(--nav-search-border, rgba(255, 255, 255, 0.16));
259
+ border-radius: var(--nav-search-radius, 10px);
260
+ background-color: var(--nav-search-bg, rgba(255, 255, 255, 0.12));
261
+ transition: var(--chrome-transition, all 0.3s ease);
262
+ }
263
+
264
+ .nav-search:focus-within {
265
+ border-color: var(--nav-search-border-focus, rgba(255, 255, 255, 0.5));
266
+ background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
267
+ }
268
+
269
+ .nav-search-icon {
270
+ flex: 0 0 auto;
271
+ width: 1rem;
272
+ height: 1rem;
273
+ color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
274
+ stroke-width: 2;
275
+ }
276
+
277
+ .nav-search-input {
278
+ flex: 1 1 auto;
279
+ min-width: 0;
280
+ width: 100%;
281
+ padding: 0.55rem 0.5rem;
282
+ border: 0;
283
+ background: transparent;
284
+ color: var(--nav-search-value, #ffffff);
285
+ font: inherit;
286
+ font-size: 0.875rem;
287
+ outline: none;
288
+ -webkit-appearance: none;
289
+ appearance: none;
290
+ }
291
+
292
+ .nav-search-input::placeholder {
293
+ color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
294
+ }
295
+
296
+ /* The field carries its own clear button: the browser's would be a second one
297
+ with a different look, and on some engines it cannot be styled at all. */
298
+ .nav-search-input::-webkit-search-cancel-button,
299
+ .nav-search-input::-webkit-search-decoration {
300
+ -webkit-appearance: none;
301
+ appearance: none;
302
+ }
303
+
304
+ .nav-search-clear {
305
+ flex: 0 0 auto;
306
+ display: inline-flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ width: 1.5rem;
310
+ height: 1.5rem;
311
+ padding: 0;
312
+ border: 0;
313
+ border-radius: 9999px;
314
+ background: transparent;
315
+ color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
316
+ cursor: pointer;
317
+ }
318
+
319
+ .nav-search-clear:hover,
320
+ .nav-search-clear:focus-visible {
321
+ color: var(--nav-search-value, #ffffff);
322
+ background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
323
+ outline: none;
324
+ }
325
+
326
+ .nav-search-clear[hidden] {
327
+ display: none;
328
+ }
329
+
330
+ .nav-search-clear-icon {
331
+ width: 0.85rem;
332
+ height: 0.85rem;
333
+ stroke-width: 2.5;
334
+ }
335
+
336
+ .nav-search-empty {
337
+ margin: 0.25rem 1.5rem 0.5rem;
338
+ color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
339
+ font-size: 0.8rem;
340
+ line-height: 1.35;
341
+ }
342
+
343
+ .nav-search-empty[hidden] {
344
+ display: none;
345
+ }
346
+
347
+ /* What the search leaves on screen: an entry that does not match goes, an entry
348
+ the match lives inside stays so the result keeps its path. */
349
+ .nav-item.nav-search-hidden {
350
+ display: none !important;
351
+ }
352
+
353
+ /* A matched submodule is indented by its depth: shown without its parent it
354
+ would otherwise look like a section of its own. The dot is the second cue, so
355
+ the depth reads even when the two items sit far from the menu's edge. */
356
+ .nav-item.nav-search-match {
357
+ padding-left: calc(
358
+ var(--nav-item-search-base, 1.5rem)
359
+ + var(--nav-search-depth, 0) * var(--nav-item-search-step, 1.1rem)
360
+ );
361
+ }
362
+
363
+ .nav-item.nav-search-nested::before {
364
+ content: "";
365
+ flex: 0 0 auto;
366
+ width: 4px;
367
+ height: 4px;
368
+ margin-right: 0.5rem;
369
+ border-radius: 9999px;
370
+ background-color: currentColor;
371
+ opacity: 0.55;
372
+ }
373
+
374
+ /* The substring that matched is marked in place, so the eye lands on it before
375
+ it reads the rest of the label. The mark's colours are the theme's: on a
376
+ saturated sidebar the text alone is not enough of a cue. */
377
+ .nav-search-hit {
378
+ padding: 0 2px;
379
+ border-radius: 3px;
380
+ background-color: var(--nav-search-hit-bg, #fde68a);
381
+ color: var(--nav-search-hit-fg, #1e3a8a);
382
+ font-weight: 600;
383
+ }
384
+
385
+ /* Where the arrow keys are. It is not hover -- the pointer may be elsewhere --
386
+ so it gets a ring that survives the row's own hover and active colours. */
387
+ .nav-item.nav-search-current {
388
+ background-color: var(--nav-search-current-bg, rgba(255, 255, 255, 0.2));
389
+ box-shadow: inset 0 0 0 1px var(--nav-search-current-ring, rgba(255, 255, 255, 0.55));
390
+ }
391
+
227
392
  /* Collapsed sidebar. Declared once, for the whole panel, and only for the wide
228
393
  layout: below the breakpoint the panel is a drawer, so a collapsed flag saved
229
394
  on a desktop would narrow the drawer and shift the content 64px to the right
@@ -238,6 +403,14 @@
238
403
  display: none;
239
404
  }
240
405
 
406
+ /* The field belongs to the wide menu: collapsed, there is no room for it and
407
+ nothing to type into. The script clears the filter on the way down, so no
408
+ section is left hidden behind a field nobody can see. */
409
+ :root:not(.is-narrow) .sidebar-collapsed .nav-search,
410
+ :root:not(.is-narrow) .sidebar-collapsed .nav-search-empty {
411
+ display: none;
412
+ }
413
+
241
414
  :root:not(.is-narrow) .sidebar-collapsed .logo-text {
242
415
  display: none;
243
416
  }