raztodo-web 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. raztodo_web/__init__.py +0 -0
  2. raztodo_web/__main__.py +36 -0
  3. raztodo_web/app/__init__.py +0 -0
  4. raztodo_web/app/dependencies.py +40 -0
  5. raztodo_web/app/factory.py +39 -0
  6. raztodo_web/features/explain/__init__.py +0 -0
  7. raztodo_web/features/explain/routes.py +42 -0
  8. raztodo_web/features/tasks/helpers.py +23 -0
  9. raztodo_web/features/tasks/routes.py +202 -0
  10. raztodo_web/features/tasks/schemas.py +48 -0
  11. raztodo_web/web/static/css/base.css +22 -0
  12. raztodo_web/web/static/css/components/forms.css +216 -0
  13. raztodo_web/web/static/css/components/modal.css +157 -0
  14. raztodo_web/web/static/css/components/sidebar.css +88 -0
  15. raztodo_web/web/static/css/components/toast.css +32 -0
  16. raztodo_web/web/static/css/features/tasks.css +251 -0
  17. raztodo_web/web/static/css/layout.css +79 -0
  18. raztodo_web/web/static/css/responsive.css +44 -0
  19. raztodo_web/web/static/css/style.css +28 -0
  20. raztodo_web/web/static/css/theme.css +90 -0
  21. raztodo_web/web/static/css/tokens.css +59 -0
  22. raztodo_web/web/static/img/export.svg +1 -0
  23. raztodo_web/web/static/img/favicon.ico +0 -0
  24. raztodo_web/web/static/img/import.svg +1 -0
  25. raztodo_web/web/static/img/search.svg +1 -0
  26. raztodo_web/web/static/img/trash.svg +1 -0
  27. raztodo_web/web/static/js/app.js +11 -0
  28. raztodo_web/web/static/js/explain/index.js +19 -0
  29. raztodo_web/web/static/js/explain/modal.js +111 -0
  30. raztodo_web/web/static/js/shared/api.js +13 -0
  31. raztodo_web/web/static/js/shared/priority.js +94 -0
  32. raztodo_web/web/static/js/shared/state.js +12 -0
  33. raztodo_web/web/static/js/shared/theme.js +30 -0
  34. raztodo_web/web/static/js/shared/toast.js +23 -0
  35. raztodo_web/web/static/js/tasks/actions.js +142 -0
  36. raztodo_web/web/static/js/tasks/edit.js +55 -0
  37. raztodo_web/web/static/js/tasks/index.js +70 -0
  38. raztodo_web/web/static/js/tasks/render.js +143 -0
  39. raztodo_web/web/templates/index.html +49 -0
  40. raztodo_web/web/templates/partials/_explain_modal.html +20 -0
  41. raztodo_web/web/templates/partials/_main_header.html +18 -0
  42. raztodo_web/web/templates/partials/_sidebar_new_task.html +70 -0
  43. raztodo_web/web/templates/partials/_sidebar_search.html +27 -0
  44. raztodo_web/web/templates/partials/_sidebar_tools.html +41 -0
  45. raztodo_web/web/templates/partials/_toast.html +1 -0
  46. raztodo_web-0.1.0.dist-info/METADATA +126 -0
  47. raztodo_web-0.1.0.dist-info/RECORD +49 -0
  48. raztodo_web-0.1.0.dist-info/WHEEL +4 -0
  49. raztodo_web-0.1.0.dist-info/entry_points.txt +3 -0
File without changes
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def web_host() -> str:
7
+ return os.getenv("RAZTODO_WEB_HOST", "127.0.0.1")
8
+
9
+
10
+ def web_port() -> int:
11
+ return int(os.getenv("RAZTODO_WEB_PORT", "8000"))
12
+
13
+
14
+ def main() -> None:
15
+ import importlib.util
16
+
17
+ if (
18
+ importlib.util.find_spec("fastapi") is None
19
+ or importlib.util.find_spec("uvicorn") is None
20
+ ):
21
+ raise SystemExit(
22
+ "Web dependencies are not installed. Install with: pip install 'raztodo[web]'"
23
+ )
24
+
25
+ import uvicorn # type: ignore[import]
26
+
27
+ uvicorn.run(
28
+ "raztodo_web.app.factory:app",
29
+ host=web_host(),
30
+ port=web_port(),
31
+ reload=False,
32
+ )
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
File without changes
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from fastapi import Depends
6
+ from raztodo.application.factory import DefaultUseCaseFactory
7
+ from raztodo.infrastructure.container import build_container
8
+ from raztodo.infrastructure.sqlite.task_repository import SQLiteTaskRepository
9
+
10
+ _container = build_container()
11
+
12
+
13
+ def get_storage() -> SQLiteTaskRepository:
14
+ return _container.repo_singleton()
15
+
16
+
17
+ def get_factory() -> DefaultUseCaseFactory:
18
+ return DefaultUseCaseFactory()
19
+
20
+
21
+ StorageDep = Annotated[SQLiteTaskRepository, Depends(get_storage)]
22
+ FactoryDep = Annotated[DefaultUseCaseFactory, Depends(get_factory)]
23
+
24
+
25
+ def get_use_case(factory_method: str):
26
+ def _dependency(storage: StorageDep, factory: FactoryDep) -> Any:
27
+ return getattr(factory, factory_method)(storage)
28
+
29
+ return _dependency
30
+
31
+
32
+ get_list_uc = get_use_case("create_list_tasks")
33
+ get_create_uc = get_use_case("create_create_task")
34
+ get_update_uc = get_use_case("create_update_task")
35
+ get_delete_uc = get_use_case("create_delete_task")
36
+ get_clear_uc = get_use_case("create_clear_tasks")
37
+ get_mark_done_uc = get_use_case("create_mark_done")
38
+ get_export_uc = get_use_case("create_export_tasks")
39
+ get_import_uc = get_use_case("create_import_tasks")
40
+ get_explain_uc = get_use_case("create_explain_task")
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.templating import Jinja2Templates
8
+ from raztodo.infrastructure.version import get_version
9
+
10
+ from raztodo_web.features.explain.routes import router as explain_router
11
+ from raztodo_web.features.tasks.routes import router as tasks_router
12
+
13
+ _WEB_DIR = Path(__file__).resolve().parent.parent / "web"
14
+ _STATIC_DIR = _WEB_DIR / "static"
15
+ _TEMPLATES_DIR = _WEB_DIR / "templates"
16
+
17
+
18
+ app = FastAPI(
19
+ title="RazTodo",
20
+ description="Local web interface for RazTodo",
21
+ version=get_version(),
22
+ )
23
+
24
+ app.mount(
25
+ "/static",
26
+ StaticFiles(directory=_STATIC_DIR),
27
+ name="static",
28
+ )
29
+
30
+ templates = Jinja2Templates(directory=_TEMPLATES_DIR)
31
+
32
+ app.include_router(tasks_router)
33
+ app.include_router(explain_router)
34
+
35
+
36
+ @app.get("/", include_in_schema=False)
37
+ async def index(request: Request):
38
+ """Serve the single-page UI, rendered from templates/index.html."""
39
+ return templates.TemplateResponse(request, "index.html")
File without changes
@@ -0,0 +1,42 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from fastapi.responses import StreamingResponse
5
+ from raztodo.domain.exceptions import RazTodoException
6
+
7
+ from raztodo_web.app.dependencies import get_explain_uc
8
+
9
+ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
10
+
11
+
12
+ @router.get("/{task_id}/explain")
13
+ def explain_task(
14
+ task_id: int,
15
+ mode: str = "short",
16
+ uc: Any = Depends(get_explain_uc), # noqa: B008
17
+ ) -> StreamingResponse:
18
+ """
19
+ Stream the LLM explanation as Server-Sent Events (SSE).
20
+ Each event: data: <token>\n\n
21
+ Final event: data: [DONE]\n\n
22
+ """
23
+ if mode not in ("short", "deep", "plan"):
24
+ raise HTTPException(
25
+ status_code=422, detail="mode must be: short, deep, or plan"
26
+ )
27
+
28
+ def _sse_generator():
29
+ try:
30
+ for token in uc.stream(task_id, mode=mode):
31
+ safe = token.replace("\n", "\\n")
32
+ yield f"data: {safe}\n\n"
33
+ except RazTodoException as _exc:
34
+ yield "event: error\ndata: An internal error occurred.\n\n"
35
+ finally:
36
+ yield "data: [DONE]\n\n"
37
+
38
+ return StreamingResponse(
39
+ _sse_generator(),
40
+ media_type="text/event-stream",
41
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
42
+ )
@@ -0,0 +1,23 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ from raztodo_web.features.tasks.schemas import TaskResponse
5
+
6
+
7
+ def remove_file(path: str) -> None:
8
+ if os.path.exists(path):
9
+ os.unlink(path)
10
+
11
+
12
+ def task_to_response(task: Any) -> TaskResponse:
13
+ return TaskResponse(
14
+ id=task.id,
15
+ title=task.title,
16
+ description=getattr(task, "description", "") or "",
17
+ done=getattr(task, "done", False),
18
+ created_at=getattr(task, "created_at", "") or "",
19
+ priority=getattr(task, "priority", "") or "",
20
+ due_date=getattr(task, "due_date", None),
21
+ tags=list(getattr(task, "tags", None) or []),
22
+ project=getattr(task, "project", None),
23
+ )
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
9
+ from fastapi.responses import FileResponse
10
+ from raztodo.domain.exceptions import RazTodoException
11
+
12
+ from raztodo_web.app.dependencies import (
13
+ get_clear_uc,
14
+ get_create_uc,
15
+ get_delete_uc,
16
+ get_export_uc,
17
+ get_import_uc,
18
+ get_list_uc,
19
+ get_mark_done_uc,
20
+ get_update_uc,
21
+ )
22
+ from raztodo_web.features.tasks.helpers import task_to_response
23
+ from raztodo_web.features.tasks.schemas import (
24
+ ClearResponse,
25
+ ImportResponse,
26
+ TaskCreate,
27
+ TaskResponse,
28
+ TaskUpdate,
29
+ )
30
+
31
+ router = APIRouter(prefix="/api/tasks", tags=["tasks"])
32
+
33
+
34
+ def _domain_error(e: Exception) -> HTTPException:
35
+ return HTTPException(status_code=400, detail=str(e))
36
+
37
+
38
+ def _remove_file(path: str) -> None:
39
+ if os.path.exists(path):
40
+ os.unlink(path)
41
+
42
+
43
+ @router.get("", response_model=list[TaskResponse])
44
+ def list_tasks(
45
+ q: str | None = None,
46
+ uc: Any = Depends(get_list_uc), # noqa: B008
47
+ ) -> list[TaskResponse]:
48
+ try:
49
+ tasks = uc.execute()
50
+ if q:
51
+ q_lower = q.lower()
52
+ tasks = [
53
+ t
54
+ for t in tasks
55
+ if q_lower in t.title.lower()
56
+ or q_lower in (getattr(t, "description", "") or "").lower()
57
+ ]
58
+ return [task_to_response(t) for t in tasks]
59
+ except RazTodoException as e:
60
+ raise _domain_error(e) from e
61
+
62
+
63
+ @router.post("", response_model=TaskResponse, status_code=201)
64
+ def create_task(
65
+ body: TaskCreate,
66
+ list_uc: Any = Depends(get_list_uc), # noqa: B008
67
+ create_uc: Any = Depends(get_create_uc), # noqa: B008
68
+ ) -> TaskResponse:
69
+ try:
70
+ task_id: int = create_uc.execute(
71
+ title=body.title,
72
+ description=body.description,
73
+ priority=body.priority or "",
74
+ due_date=body.due_date,
75
+ tags=body.tags or [],
76
+ project=body.project,
77
+ )
78
+ tasks = list_uc.execute()
79
+ task = next((t for t in tasks if t.id == task_id), None)
80
+ if task is None:
81
+ raise HTTPException(
82
+ status_code=500, detail="Task created but could not be retrieved"
83
+ )
84
+ return task_to_response(task)
85
+ except RazTodoException as e:
86
+ raise _domain_error(e) from e
87
+
88
+
89
+ @router.get("/export")
90
+ def export_tasks(
91
+ background_tasks: BackgroundTasks,
92
+ uc: Any = Depends(get_export_uc), # noqa: B008
93
+ ) -> FileResponse:
94
+ try:
95
+ fd, path = tempfile.mkstemp(suffix=".json")
96
+ os.close(fd)
97
+
98
+ uc.execute(path)
99
+ background_tasks.add_task(_remove_file, path)
100
+
101
+ return FileResponse(
102
+ path=path,
103
+ media_type="application/json",
104
+ filename="raztodo_export.json",
105
+ )
106
+ except RazTodoException as e:
107
+ raise _domain_error(e) from e
108
+
109
+
110
+ @router.post("/import", response_model=ImportResponse)
111
+ def import_tasks(
112
+ tasks: list[TaskCreate],
113
+ uc: Any = Depends(get_import_uc), # noqa: B008
114
+ ) -> ImportResponse:
115
+ tmp = tempfile.NamedTemporaryFile( # noqa: SIM115
116
+ suffix=".json", delete=False, mode="w", encoding="utf-8"
117
+ )
118
+ try:
119
+ json.dump([t.model_dump() for t in tasks], tmp, ensure_ascii=False)
120
+ tmp.close()
121
+ result = uc.execute(tmp.name, upsert=True)
122
+ if isinstance(result, dict):
123
+ return ImportResponse(
124
+ inserted=result.get("inserted", 0),
125
+ updated=result.get("updated", 0),
126
+ )
127
+ return ImportResponse(inserted=int(result), updated=0)
128
+ except RazTodoException as e:
129
+ raise _domain_error(e) from e
130
+ except Exception as e:
131
+ raise HTTPException(status_code=422, detail=f"Import failed: {e}") from e
132
+ finally:
133
+ tmp.close()
134
+ if os.path.exists(tmp.name):
135
+ os.unlink(tmp.name)
136
+
137
+
138
+ @router.post("/clear", response_model=ClearResponse)
139
+ def clear_tasks(uc: Any = Depends(get_clear_uc)) -> ClearResponse: # noqa: B008
140
+ try:
141
+ deleted: int = uc.execute(confirmed=True)
142
+ return ClearResponse(deleted=deleted)
143
+ except RazTodoException as e:
144
+ raise _domain_error(e) from e
145
+
146
+
147
+ @router.put("/{task_id}", response_model=TaskResponse)
148
+ def update_task(
149
+ task_id: int,
150
+ body: TaskUpdate,
151
+ list_uc: Any = Depends(get_list_uc), # noqa: B008
152
+ update_uc: Any = Depends(get_update_uc), # noqa: B008
153
+ ) -> TaskResponse:
154
+ try:
155
+ update_uc.execute(
156
+ task_id,
157
+ title=body.title,
158
+ description=body.description,
159
+ priority=body.priority if body.priority is not None else "",
160
+ due_date=body.due_date if body.due_date is not None else "",
161
+ tags=body.tags if body.tags is not None else [],
162
+ project=body.project if body.project is not None else "",
163
+ )
164
+ tasks = list_uc.execute()
165
+ task = next((t for t in tasks if t.id == task_id), None)
166
+ if task is None:
167
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
168
+ return task_to_response(task)
169
+ except RazTodoException as e:
170
+ raise _domain_error(e) from e
171
+
172
+
173
+ @router.delete("/{task_id}", status_code=204)
174
+ def delete_task(
175
+ task_id: int,
176
+ uc: Any = Depends(get_delete_uc), # noqa: B008
177
+ ) -> None:
178
+ try:
179
+ uc.execute(task_id)
180
+ except RazTodoException as e:
181
+ raise HTTPException(status_code=404, detail=str(e)) from e
182
+
183
+
184
+ @router.patch("/{task_id}/done", response_model=TaskResponse)
185
+ def toggle_done(
186
+ task_id: int,
187
+ list_uc: Any = Depends(get_list_uc), # noqa: B008
188
+ mark_uc: Any = Depends(get_mark_done_uc), # noqa: B008
189
+ ) -> TaskResponse:
190
+ try:
191
+ tasks = list_uc.execute()
192
+ task = next((t for t in tasks if t.id == task_id), None)
193
+ if task is None:
194
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
195
+ mark_uc.execute(task_id, done=not task.done)
196
+ tasks = list_uc.execute()
197
+ task = next((t for t in tasks if t.id == task_id), None)
198
+ if task is None:
199
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
200
+ return task_to_response(task)
201
+ except RazTodoException as e:
202
+ raise _domain_error(e) from e
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class TaskCreate(BaseModel):
7
+ title: str = Field(..., min_length=1, max_length=60)
8
+ description: str = Field(default="")
9
+ priority: str | None = Field(default=None, pattern="^[LMH]$")
10
+ due_date: str | None = Field(default=None)
11
+ tags: list[str] = Field(default_factory=list)
12
+ project: str | None = Field(default=None)
13
+
14
+
15
+ class TaskUpdate(BaseModel):
16
+ title: str | None = Field(default=None, min_length=1, max_length=60)
17
+ description: str | None = Field(default=None)
18
+ priority: str | None = Field(default=None, pattern="^[LMH]$")
19
+ due_date: str | None = Field(default=None)
20
+ tags: list[str] | None = Field(default=None)
21
+ project: str | None = Field(default=None)
22
+
23
+
24
+ class TaskResponse(BaseModel):
25
+ id: int
26
+ title: str
27
+ description: str = ""
28
+ done: bool = False
29
+ created_at: str = ""
30
+ priority: str = ""
31
+ due_date: str | None = None
32
+ tags: list[str] = Field(default_factory=list)
33
+ project: str | None = None
34
+
35
+
36
+ class ImportPayload(BaseModel):
37
+ """Raw JSON list of task dicts — validated at use-case level."""
38
+
39
+ model_config = {"arbitrary_types_allowed": True}
40
+
41
+
42
+ class ClearResponse(BaseModel):
43
+ deleted: int
44
+
45
+
46
+ class ImportResponse(BaseModel):
47
+ inserted: int
48
+ updated: int
@@ -0,0 +1,22 @@
1
+ /* ── Reset ─────────────────────────────────────────── */
2
+ *,
3
+ *::before,
4
+ *::after {
5
+ box-sizing: border-box;
6
+ margin: 0;
7
+ padding: 0;
8
+ }
9
+ html,
10
+ body {
11
+ height: 100%;
12
+ }
13
+
14
+ /* ── Base ──────────────────────────────────────────── */
15
+ body {
16
+ font-family: var(--font);
17
+ background: var(--bg-app);
18
+ color: var(--text-primary);
19
+ font-size: 15px;
20
+ line-height: 1.6;
21
+ -webkit-font-smoothing: antialiased;
22
+ }
@@ -0,0 +1,216 @@
1
+ /* ── Form elements ─────────────────────────────────── */
2
+ .form-stack {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: 8px;
6
+ }
7
+
8
+ .form-row {
9
+ display: grid;
10
+ grid-template-columns: 1fr 1fr;
11
+ gap: 8px;
12
+ }
13
+
14
+ .field {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 4px;
18
+ }
19
+
20
+ .field label {
21
+ font-size: 11px;
22
+ color: var(--text-label);
23
+ font-weight: 500;
24
+ }
25
+
26
+ input,
27
+ select {
28
+ width: 100%;
29
+ padding: 9px 11px;
30
+ border-radius: var(--r-sm);
31
+ border: 1px solid var(--border-input);
32
+ background: var(--bg-input);
33
+ color: var(--text-primary);
34
+ font-family: var(--font);
35
+ font-size: 14px;
36
+ transition:
37
+ border-color var(--t),
38
+ box-shadow var(--t);
39
+ }
40
+
41
+ input:hover,
42
+ select:hover {
43
+ border-color: var(--border-focus);
44
+ }
45
+
46
+
47
+ input::placeholder {
48
+ color: var(--text-muted);
49
+ }
50
+
51
+
52
+ input:focus,
53
+ select:focus {
54
+ outline: none;
55
+ border-color: var(--border-focus);
56
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
57
+ }
58
+
59
+ input.input-primary {
60
+ font-size: 15px;
61
+ padding: 11px 12px;
62
+ border-color: var(--border-input);
63
+ }
64
+
65
+ select option {
66
+ background: var(--bg-panel);
67
+ }
68
+
69
+ input.input-primary:hover {
70
+ border-color: var(--border-focus);
71
+ }
72
+
73
+ /* ── Buttons ───────────────────────────────────────── */
74
+ .btn-primary {
75
+ width: 100%;
76
+ padding: 11px 16px;
77
+ border-radius: var(--r-sm);
78
+ border: none;
79
+ background: var(--accent);
80
+ color: #fff;
81
+ font-family: var(--font);
82
+ font-size: 14px;
83
+ font-weight: 500;
84
+ cursor: pointer;
85
+ transition:
86
+ background var(--t),
87
+ transform var(--t);
88
+ letter-spacing: 0.01em;
89
+ margin-top: 4px;
90
+ }
91
+ .btn-primary:hover {
92
+ background: var(--accent-hover);
93
+ }
94
+ .btn-primary:active {
95
+ transform: scale(0.98);
96
+ }
97
+
98
+ .btn-ghost {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 9px;
102
+ width: 100%;
103
+ padding: 9px 11px;
104
+ border-radius: var(--r-sm);
105
+ border: none;
106
+ background: transparent;
107
+ color: var(--text-secondary);
108
+ font-family: var(--font);
109
+ font-size: 14px;
110
+ cursor: pointer;
111
+ transition:
112
+ background var(--t),
113
+ color var(--t);
114
+ text-align: left;
115
+ }
116
+ .btn-ghost svg {
117
+ width: 17px;
118
+ height: 17px;
119
+ flex-shrink: 0;
120
+ }
121
+ .btn-ghost:hover {
122
+ background: var(--bg-hover);
123
+ color: var(--text-primary);
124
+ }
125
+
126
+ .btn-danger-ghost:hover {
127
+ background: var(--bg-hover);
128
+ }
129
+
130
+ .tool-buttons {
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 2px;
134
+ }
135
+
136
+ /* ── Search ────────────────────────────────────────── */
137
+ .search-wrap {
138
+ position: relative;
139
+ }
140
+ .search-icon {
141
+ position: absolute;
142
+ left: 9px;
143
+ top: 50%;
144
+ transform: translateY(-50%);
145
+ width: 15px;
146
+ height: 15px;
147
+ color: var(--text-muted);
148
+ pointer-events: none;
149
+ }
150
+ .search-wrap input {
151
+ padding-left: 32px;
152
+ background: var(--bg-input);
153
+ }
154
+
155
+ /* ── Priority picker ───────────────────────────────── */
156
+ .priority-picker {
157
+ display: flex;
158
+ align-items: stretch;
159
+ width: 100%;
160
+ height: 39px;
161
+ border-radius: var(--r-sm);
162
+ border: 1px solid var(--border-input);
163
+ background: var(--bg-input);
164
+ overflow: hidden;
165
+ transition: border-color var(--t), box-shadow var(--t);
166
+ }
167
+ .priority-picker:focus-within {
168
+ border-color: var(--border-focus);
169
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
170
+ }
171
+
172
+ .priority-arrow {
173
+ flex-shrink: 0;
174
+ width: 36px;
175
+ border: none;
176
+ background: transparent;
177
+ color: var(--text-secondary);
178
+ font-size: 22px;
179
+ line-height: 1;
180
+ cursor: pointer;
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ transition: background var(--t), color var(--t);
185
+ padding: 0;
186
+ }
187
+ .priority-arrow:hover:not(:disabled) {
188
+ background: var(--bg-hover);
189
+ color: var(--text-primary);
190
+ }
191
+ .priority-arrow:disabled {
192
+ opacity: 0.25;
193
+ cursor: default;
194
+ }
195
+
196
+ .priority-label {
197
+ flex: 1;
198
+ text-align: center;
199
+ font-size: 14px;
200
+ font-weight: 500;
201
+ color: var(--text-primary);
202
+ user-select: none;
203
+ pointer-events: none;
204
+ display: flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ }
208
+
209
+ /* Light theme */
210
+ [data-theme="light"] .priority-picker {
211
+ background: var(--bg-input);
212
+ border-color: var(--border-input);
213
+ }
214
+ [data-theme="light"] .priority-arrow:hover:not(:disabled) {
215
+ background: var(--bg-hover);
216
+ }