paint-tracker 0.1.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.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: paint-tracker
3
+ Version: 0.1.0
4
+ Summary: Paint and stain shelf tracker – self-updating, one-command deploy for Raspberry Pi
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/FlaccidFacade/paint-tracker
7
+ Project-URL: Bug Tracker, https://github.com/FlaccidFacade/paint-tracker/issues
8
+ Keywords: paint,tracker,raspberry-pi,home
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Home Automation
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: fastapi>=0.115
19
+ Requires-Dist: uvicorn[standard]>=0.35
20
+ Requires-Dist: sqlalchemy>=2.0
21
+ Requires-Dist: pydantic>=2.0
22
+
23
+ # paint-tracker
24
+
25
+ Paint and stain shelf tracker with:
26
+
27
+ - **FastAPI** API
28
+ - **SQLite** auto-configured database (no setup required)
29
+ - **React + Vite** frontend bundled into the Python package
30
+ - **Self-updating** – checks PyPI for new versions on every start
31
+
32
+ ---
33
+
34
+ ## 🚀 One-click install on Raspberry Pi Zero 2 W
35
+
36
+ Run this single command on the Pi (requires Python 3.11+ and internet access):
37
+
38
+ ```bash
39
+ bash <(curl -fsSL https://raw.githubusercontent.com/FlaccidFacade/paint-tracker/main/scripts/install_pi.sh)
40
+ ```
41
+
42
+ The script will:
43
+ 1. Install `paint-tracker` from PyPI.
44
+ 2. Create and enable a **systemd service** that starts automatically on every boot.
45
+ 3. Open port 8000 in the firewall (if ufw is active).
46
+ 4. Print the local URL where the UI is reachable (e.g. `http://raspberrypi.local:8000`).
47
+
48
+ After installation, the tracker is accessible from any device on your network.
49
+
50
+ ---
51
+
52
+ ## 📦 Install from PyPI (any machine)
53
+
54
+ ```bash
55
+ pip install paint-tracker
56
+ paint-tracker
57
+ ```
58
+
59
+ On first launch Paint Tracker will:
60
+ - Check PyPI for a newer version and prompt you to upgrade.
61
+ - Auto-create the SQLite database at `~/.local/share/paint-tracker/paint_tracker.db`.
62
+ - Start the web server on `http://0.0.0.0:8000`.
63
+
64
+ ### CLI options
65
+
66
+ ```
67
+ paint-tracker --help
68
+
69
+ --host HOST Bind host (default: 0.0.0.0)
70
+ --port PORT Bind port (default: 8000)
71
+ --no-update-check Skip the PyPI update check on startup
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 🔧 Development setup
77
+
78
+ ### Backend
79
+
80
+ ```bash
81
+ cd backend
82
+ pip install -r requirements.txt
83
+ pip install -e .. # install paint_tracker package in editable mode
84
+ uvicorn paint_tracker.main:app --reload
85
+ ```
86
+
87
+ `DATABASE_URL` defaults to `~/.local/share/paint-tracker/paint_tracker.db`. Override for PostgreSQL:
88
+
89
+ ```bash
90
+ export DATABASE_URL='postgresql+psycopg://user:password@localhost:5432/paint_tracker'
91
+ ```
92
+
93
+ Run tests:
94
+
95
+ ```bash
96
+ cd backend
97
+ pytest
98
+ ```
99
+
100
+ ### Frontend (standalone dev server)
101
+
102
+ ```bash
103
+ cd frontend
104
+ npm install
105
+ npm run dev
106
+ ```
107
+
108
+ Optional API override:
109
+
110
+ ```bash
111
+ export VITE_API_BASE_URL='http://localhost:8000/api'
112
+ ```
113
+
114
+ ---
115
+
116
+ ## 📤 Publishing to PyPI
117
+
118
+ Build the frontend, bundle it into the Python package, and publish in one step:
119
+
120
+ ```bash
121
+ pip install build twine
122
+ bash scripts/build_and_publish.sh # publish to PyPI
123
+ bash scripts/build_and_publish.sh --test # publish to TestPyPI
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Included behavior
129
+
130
+ - Create paints with required `room`, `shelf_level`, `shelf_depth`
131
+ - Room suggestions while typing; new room names are saved automatically
132
+ - Search paints by room, color, and coordinates
133
+ - Edit existing paints from the list (✏️ button)
134
+ - Confirmation/decline controls use green check and red X buttons
@@ -0,0 +1,112 @@
1
+ # paint-tracker
2
+
3
+ Paint and stain shelf tracker with:
4
+
5
+ - **FastAPI** API
6
+ - **SQLite** auto-configured database (no setup required)
7
+ - **React + Vite** frontend bundled into the Python package
8
+ - **Self-updating** – checks PyPI for new versions on every start
9
+
10
+ ---
11
+
12
+ ## 🚀 One-click install on Raspberry Pi Zero 2 W
13
+
14
+ Run this single command on the Pi (requires Python 3.11+ and internet access):
15
+
16
+ ```bash
17
+ bash <(curl -fsSL https://raw.githubusercontent.com/FlaccidFacade/paint-tracker/main/scripts/install_pi.sh)
18
+ ```
19
+
20
+ The script will:
21
+ 1. Install `paint-tracker` from PyPI.
22
+ 2. Create and enable a **systemd service** that starts automatically on every boot.
23
+ 3. Open port 8000 in the firewall (if ufw is active).
24
+ 4. Print the local URL where the UI is reachable (e.g. `http://raspberrypi.local:8000`).
25
+
26
+ After installation, the tracker is accessible from any device on your network.
27
+
28
+ ---
29
+
30
+ ## 📦 Install from PyPI (any machine)
31
+
32
+ ```bash
33
+ pip install paint-tracker
34
+ paint-tracker
35
+ ```
36
+
37
+ On first launch Paint Tracker will:
38
+ - Check PyPI for a newer version and prompt you to upgrade.
39
+ - Auto-create the SQLite database at `~/.local/share/paint-tracker/paint_tracker.db`.
40
+ - Start the web server on `http://0.0.0.0:8000`.
41
+
42
+ ### CLI options
43
+
44
+ ```
45
+ paint-tracker --help
46
+
47
+ --host HOST Bind host (default: 0.0.0.0)
48
+ --port PORT Bind port (default: 8000)
49
+ --no-update-check Skip the PyPI update check on startup
50
+ ```
51
+
52
+ ---
53
+
54
+ ## 🔧 Development setup
55
+
56
+ ### Backend
57
+
58
+ ```bash
59
+ cd backend
60
+ pip install -r requirements.txt
61
+ pip install -e .. # install paint_tracker package in editable mode
62
+ uvicorn paint_tracker.main:app --reload
63
+ ```
64
+
65
+ `DATABASE_URL` defaults to `~/.local/share/paint-tracker/paint_tracker.db`. Override for PostgreSQL:
66
+
67
+ ```bash
68
+ export DATABASE_URL='postgresql+psycopg://user:password@localhost:5432/paint_tracker'
69
+ ```
70
+
71
+ Run tests:
72
+
73
+ ```bash
74
+ cd backend
75
+ pytest
76
+ ```
77
+
78
+ ### Frontend (standalone dev server)
79
+
80
+ ```bash
81
+ cd frontend
82
+ npm install
83
+ npm run dev
84
+ ```
85
+
86
+ Optional API override:
87
+
88
+ ```bash
89
+ export VITE_API_BASE_URL='http://localhost:8000/api'
90
+ ```
91
+
92
+ ---
93
+
94
+ ## 📤 Publishing to PyPI
95
+
96
+ Build the frontend, bundle it into the Python package, and publish in one step:
97
+
98
+ ```bash
99
+ pip install build twine
100
+ bash scripts/build_and_publish.sh # publish to PyPI
101
+ bash scripts/build_and_publish.sh --test # publish to TestPyPI
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Included behavior
107
+
108
+ - Create paints with required `room`, `shelf_level`, `shelf_depth`
109
+ - Room suggestions while typing; new room names are saved automatically
110
+ - Search paints by room, color, and coordinates
111
+ - Edit existing paints from the list (✏️ button)
112
+ - Confirmation/decline controls use green check and red X buttons
@@ -0,0 +1,5 @@
1
+ """Paint Tracker – paint and stain shelf tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m paint_tracker` to launch the server."""
2
+
3
+ from paint_tracker.cli import main
4
+
5
+ main()
@@ -0,0 +1,119 @@
1
+ """Command-line entry point for the Paint Tracker server.
2
+
3
+ Usage::
4
+
5
+ paint-tracker [--host HOST] [--port PORT] [--no-update-check]
6
+
7
+ On startup the CLI will:
8
+
9
+ 1. Check PyPI for a newer version and prompt the user to upgrade.
10
+ 2. Auto-configure the database (SQLite at
11
+ ``~/.local/share/paint-tracker/paint_tracker.db`` unless
12
+ ``DATABASE_URL`` is set).
13
+ 3. Launch the uvicorn ASGI server.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import importlib.metadata
20
+ import subprocess
21
+ import sys
22
+
23
+ from paint_tracker import __version__
24
+ from paint_tracker.updater import check_for_update
25
+
26
+ _BANNER = r"""
27
+ ____ _ _ _____ _
28
+ | _ \ __ _(_)_ __ | |_ |_ _| __ __ _ __| | _____ _ __
29
+ | |_) / _` | | '_ \| __| | || '__/ _` |/ _` |/ / _ \ '__|
30
+ | __/ (_| | | | | | |_ | || | | (_| | (_| | < __/ |
31
+ |_| \__,_|_|_| |_|\__| |_||_| \__,_|\__,_|\_\___|_|
32
+
33
+ """
34
+
35
+
36
+ def _prompt_update(new_version: str) -> bool:
37
+ """Return *True* if the user agrees to upgrade."""
38
+ print(f"\n🔔 A new version of paint-tracker is available: {new_version}")
39
+ print(f" Currently installed: {__version__}")
40
+ try:
41
+ answer = input(" Upgrade now? [Y/n] ").strip().lower()
42
+ except EOFError:
43
+ return False
44
+ return answer in ("", "y", "yes")
45
+
46
+
47
+ def _do_upgrade() -> None:
48
+ """Run ``pip install --upgrade paint-tracker`` and restart the process."""
49
+ print("⬆️ Upgrading paint-tracker …")
50
+ subprocess.check_call(
51
+ [sys.executable, "-m", "pip", "install", "--upgrade", "paint-tracker"],
52
+ stdout=sys.stdout,
53
+ stderr=sys.stderr,
54
+ )
55
+ print("✅ Upgrade complete – restarting …\n")
56
+ # Replace the current process with the freshly installed version.
57
+ import os
58
+
59
+ os.execv(sys.executable, [sys.executable, "-m", "paint_tracker"] + sys.argv[1:])
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> None:
63
+ parser = argparse.ArgumentParser(
64
+ prog="paint-tracker",
65
+ description="Start the Paint Tracker server.",
66
+ )
67
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
68
+ parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
69
+ parser.add_argument(
70
+ "--no-update-check",
71
+ action="store_true",
72
+ help="Skip the PyPI update check on startup.",
73
+ )
74
+ args = parser.parse_args(argv)
75
+
76
+ print(_BANNER)
77
+
78
+ try:
79
+ installed = importlib.metadata.version("paint-tracker")
80
+ except importlib.metadata.PackageNotFoundError:
81
+ installed = __version__
82
+ print(f"Paint Tracker v{installed}")
83
+
84
+ # ── 1. Update check ──────────────────────────────────────────────────────
85
+ if not args.no_update_check:
86
+ print("🔍 Checking PyPI for updates …", end=" ", flush=True)
87
+ new_version = check_for_update()
88
+ if new_version:
89
+ print() # newline after the ellipsis
90
+ if _prompt_update(new_version):
91
+ _do_upgrade()
92
+ return # unreachable after execv, but keeps linters happy
93
+ else:
94
+ print("up to date.")
95
+
96
+ # ── 2. Database auto-configuration ───────────────────────────────────────
97
+ # Importing the database module triggers directory creation and engine setup.
98
+ from paint_tracker.database import DATABASE_URL # noqa: F401
99
+
100
+ print(f"\n🗄️ Database: {DATABASE_URL}")
101
+
102
+ # ── 3. Start server ───────────────────────────────────────────────────────
103
+ print(f"🚀 Starting server on http://{args.host}:{args.port}\n")
104
+
105
+ try:
106
+ import uvicorn
107
+ except ImportError as exc:
108
+ sys.exit(f"uvicorn is required to run the server: {exc}")
109
+
110
+ uvicorn.run(
111
+ "paint_tracker.main:app",
112
+ host=args.host,
113
+ port=args.port,
114
+ reload=False,
115
+ )
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
@@ -0,0 +1,38 @@
1
+ """Database engine / session factory with automatic data-directory creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Generator
8
+
9
+ from sqlalchemy import create_engine
10
+ from sqlalchemy.orm import Session, sessionmaker
11
+
12
+
13
+ def _default_db_path() -> str:
14
+ """Return a sensible default SQLite path inside the user's data directory.
15
+
16
+ Priority:
17
+ 1. ``DATABASE_URL`` environment variable (any SQLAlchemy URL).
18
+ 2. ``~/.local/share/paint-tracker/paint_tracker.db`` (XDG-ish default).
19
+ """
20
+ data_dir = Path.home() / ".local" / "share" / "paint-tracker"
21
+ data_dir.mkdir(parents=True, exist_ok=True)
22
+ return f"sqlite:///{data_dir / 'paint_tracker.db'}"
23
+
24
+
25
+ DATABASE_URL: str = os.getenv("DATABASE_URL") or _default_db_path()
26
+
27
+ connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
28
+
29
+ engine = create_engine(DATABASE_URL, connect_args=connect_args, future=True)
30
+ session_local = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
31
+
32
+
33
+ def get_db() -> Generator[Session, None, None]:
34
+ db = session_local()
35
+ try:
36
+ yield db
37
+ finally:
38
+ db.close()
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from difflib import get_close_matches
4
+ from pathlib import Path
5
+
6
+ from fastapi import Depends, FastAPI, HTTPException, Query
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.staticfiles import StaticFiles
9
+ from sqlalchemy import select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from .database import engine, get_db
13
+ from .models import Base, Paint, Room
14
+ from .schemas import PaintCreate, PaintOut, PaintUpdate, RoomOut
15
+
16
+ Base.metadata.create_all(bind=engine)
17
+
18
+ app = FastAPI(title="Paint Tracker API")
19
+
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_credentials=False,
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+
29
+ def _normalize_room_name(room_name: str) -> str:
30
+ return room_name.strip()
31
+
32
+
33
+ def _get_or_create_room(db: Session, room_name: str) -> Room:
34
+ normalized_name = _normalize_room_name(room_name)
35
+ existing = db.execute(select(Room).where(Room.name.ilike(normalized_name))).scalar_one_or_none()
36
+ if existing:
37
+ return existing
38
+
39
+ room = Room(name=normalized_name)
40
+ db.add(room)
41
+ db.flush()
42
+ return room
43
+
44
+
45
+ def _paint_to_schema(paint: Paint) -> PaintOut:
46
+ return PaintOut(
47
+ id=paint.id,
48
+ color_code=paint.color_code,
49
+ color_name=paint.color_name,
50
+ finish_style=paint.finish_style,
51
+ bucket_image_url=paint.bucket_image_url,
52
+ notes=paint.notes,
53
+ shelf_level=paint.shelf_level,
54
+ shelf_depth=paint.shelf_depth,
55
+ room=paint.room.name,
56
+ )
57
+
58
+
59
+ @app.get("/api/paints", response_model=list[PaintOut])
60
+ def list_paints(
61
+ query: str | None = Query(default=None, max_length=100),
62
+ db: Session = Depends(get_db),
63
+ ) -> list[PaintOut]:
64
+ statement = select(Paint).join(Room)
65
+ if query:
66
+ q = f"%{query.strip()}%"
67
+ statement = statement.where(
68
+ Paint.color_name.ilike(q)
69
+ | Paint.color_code.ilike(q)
70
+ | Room.name.ilike(q)
71
+ | Paint.shelf_level.ilike(q)
72
+ | Paint.shelf_depth.ilike(q)
73
+ )
74
+
75
+ paints = db.execute(statement.order_by(Room.name, Paint.shelf_level, Paint.shelf_depth)).scalars().all()
76
+ return [_paint_to_schema(paint) for paint in paints]
77
+
78
+
79
+ @app.post("/api/paints", response_model=PaintOut, status_code=201)
80
+ def create_paint(payload: PaintCreate, db: Session = Depends(get_db)) -> PaintOut:
81
+ room = _get_or_create_room(db, payload.room)
82
+ paint = Paint(
83
+ color_code=payload.color_code,
84
+ color_name=payload.color_name,
85
+ finish_style=payload.finish_style,
86
+ bucket_image_url=payload.bucket_image_url,
87
+ notes=payload.notes,
88
+ shelf_level=payload.shelf_level,
89
+ shelf_depth=payload.shelf_depth,
90
+ room_id=room.id,
91
+ )
92
+ db.add(paint)
93
+ db.commit()
94
+ db.refresh(paint)
95
+ db.refresh(paint, attribute_names=["room"])
96
+ return _paint_to_schema(paint)
97
+
98
+
99
+ @app.put("/api/paints/{paint_id}", response_model=PaintOut)
100
+ def update_paint(paint_id: int, payload: PaintUpdate, db: Session = Depends(get_db)) -> PaintOut:
101
+ paint = db.get(Paint, paint_id)
102
+ if not paint:
103
+ raise HTTPException(status_code=404, detail="Paint not found")
104
+
105
+ room = _get_or_create_room(db, payload.room)
106
+ paint.color_code = payload.color_code
107
+ paint.color_name = payload.color_name
108
+ paint.finish_style = payload.finish_style
109
+ paint.bucket_image_url = payload.bucket_image_url
110
+ paint.notes = payload.notes
111
+ paint.shelf_level = payload.shelf_level
112
+ paint.shelf_depth = payload.shelf_depth
113
+ paint.room_id = room.id
114
+
115
+ db.commit()
116
+ db.refresh(paint)
117
+ db.refresh(paint, attribute_names=["room"])
118
+ return _paint_to_schema(paint)
119
+
120
+
121
+ @app.get("/api/rooms", response_model=list[RoomOut])
122
+ def list_rooms(
123
+ suggest: str | None = Query(default=None, max_length=30),
124
+ db: Session = Depends(get_db),
125
+ ) -> list[RoomOut]:
126
+ rooms = db.execute(select(Room).order_by(Room.name)).scalars().all()
127
+ if not suggest:
128
+ return rooms
129
+
130
+ room_names = [room.name for room in rooms]
131
+ suggestion = suggest.strip()
132
+ prefix_matches = [name for name in room_names if suggestion.lower() in name.lower()]
133
+ close_matches = get_close_matches(suggestion, room_names, n=5, cutoff=0.5)
134
+ ordered_names = list(dict.fromkeys(prefix_matches + close_matches))
135
+
136
+ return [room for room in rooms if room.name in ordered_names]
137
+
138
+
139
+ # Serve the pre-built React frontend when it exists inside the package.
140
+ # This mount must be registered AFTER all API routes so that API paths take
141
+ # priority over the catch-all "/" prefix handled by StaticFiles.
142
+ _STATIC_DIR = Path(__file__).parent / "static"
143
+ if (_STATIC_DIR / "index.html").exists():
144
+ app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import ForeignKey, String
4
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
5
+
6
+
7
+ class Base(DeclarativeBase):
8
+ pass
9
+
10
+
11
+ class Room(Base):
12
+ __tablename__ = "rooms"
13
+
14
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
15
+ name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
16
+ paints: Mapped[list["Paint"]] = relationship(back_populates="room", cascade="all, delete-orphan")
17
+
18
+
19
+ class Paint(Base):
20
+ __tablename__ = "paints"
21
+
22
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
23
+ color_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
24
+ color_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
25
+ finish_style: Mapped[str | None] = mapped_column(String(50), nullable=True)
26
+ bucket_image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
27
+ notes: Mapped[str | None] = mapped_column(String(500), nullable=True)
28
+ shelf_level: Mapped[str] = mapped_column(String(10), index=True)
29
+ shelf_depth: Mapped[str] = mapped_column(String(10), index=True)
30
+ room_id: Mapped[int] = mapped_column(ForeignKey("rooms.id"), index=True)
31
+ room: Mapped[Room] = relationship(back_populates="paints")
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class PaintBase(BaseModel):
7
+ color_code: str | None = Field(default=None, max_length=50)
8
+ color_name: str | None = Field(default=None, max_length=100)
9
+ finish_style: str | None = Field(default=None, max_length=50)
10
+ bucket_image_url: str | None = Field(default=None, max_length=500)
11
+ notes: str | None = Field(default=None, max_length=500)
12
+ shelf_level: str = Field(min_length=1, max_length=10)
13
+ shelf_depth: str = Field(min_length=1, max_length=10)
14
+ room: str = Field(min_length=1, max_length=30)
15
+
16
+
17
+ class PaintCreate(PaintBase):
18
+ pass
19
+
20
+
21
+ class PaintUpdate(PaintBase):
22
+ pass
23
+
24
+
25
+ class PaintOut(PaintBase):
26
+ id: int
27
+
28
+ model_config = ConfigDict(from_attributes=True)
29
+
30
+
31
+ class RoomOut(BaseModel):
32
+ id: int
33
+ name: str
34
+
35
+ model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1 @@
1
+ :root{color:#111827;background-color:#f3f4f6;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{margin:0}#root{min-height:100vh}.layout{gap:1rem;max-width:960px;margin:0 auto;padding:2rem 1rem 4rem;display:grid}.card{background:#fff;border-radius:12px;padding:1rem;box-shadow:0 3px 12px #11182714}.grid{grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:.75rem;display:grid}label{gap:.25rem;font-size:.9rem;display:grid}input,textarea,button{font:inherit}input,textarea{border:1px solid #d1d5db;border-radius:8px;padding:.5rem}textarea{min-height:72px}.full-width{grid-column:1/-1}.actions{gap:.5rem;margin-top:.75rem;display:flex}.actions button,.edit{cursor:pointer;border:none;border-radius:999px;width:2.25rem;height:2.25rem}.confirm{color:#fff;background-color:#22c55e}.decline{color:#fff;background-color:#ef4444}.edit{background-color:#e5e7eb}.search-row{gap:.5rem;margin-bottom:.75rem;display:flex}.search-row button{color:#fff;background-color:#111827;border:none;border-radius:8px;padding:.5rem .75rem}.paint-list{gap:.5rem;margin:0;padding:0;list-style:none;display:grid}.paint-list li{border:1px solid #e5e7eb;border-radius:8px;justify-content:space-between;align-items:center;padding:.75rem;display:flex}.paint-list p{margin:.1rem 0}.error{color:#b91c1c}