diff --git a/README.md b/README.md index 3cf78e4..de73639 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning - **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins. - **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options. Break timer state is fully restored when navigating away and returning to the dashboard. - **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break. +- **Rules & Expectations** — Define a list of household rules or expectations in Admin → Rules & Expectations. Items can be reordered by dragging with the handle on the left. From the Dashboard, press the **Rules/Expectations** button in the Overlays card to show them as a full-screen numbered overlay on the TV. The button turns highlighted with a pulsing **LIVE** badge while the overlay is active. Press it again to dismiss. Tapping anywhere on the TV overlay also dismisses it locally. +- **Dashboard Overlays** — A dedicated card on the parent Dashboard groups controls that push full-screen content to the TV. Currently contains the Rules/Expectations overlay button; designed to accommodate additional overlay types in the future. - **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **Start** and **Finish**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks. - **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page. - **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, **Done** to mark it as fully complete, and **Reset** to clear the elapsed time back to zero (timer stays paused). Elapsed time per block is remembered across switches, so returning to a block picks up where it left off. @@ -76,6 +78,7 @@ homeschool/ │ │ ├── activity.py # ActivityLog (manual notes) │ │ ├── morning_routine.py# MorningRoutineItem │ │ ├── break_activity.py # BreakActivityItem +│ │ ├── rule.py # RuleItem (rules & expectations) │ │ ├── strike.py # StrikeEvent (strike history) │ │ └── user.py # User (incl. timezone, last_active_at) │ ├── schemas/ # Pydantic request/response schemas @@ -88,6 +91,8 @@ homeschool/ │ │ ├── logs.py # Timeline + strike events │ │ ├── morning_routine.py │ │ ├── break_activity.py # Break activities CRUD +│ │ ├── rules.py # Rules & Expectations CRUD + bulk reorder +│ │ ├── overlays.py # WS broadcast: show/hide rules overlay │ │ ├── dashboard.py # Public snapshot endpoint (TV + dashboard reload) │ │ ├── admin.py # Super admin: login, user list, impersonate, reset-password, │ │ │ # toggle-active, delete-user @@ -178,10 +183,11 @@ Open **http://localhost:8054/login** and register. This creates your admin accou 2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it. 3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts. 4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active. -5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. -6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. -7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template -8. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV. +5. **Admin** → Add **Rules & Expectations** items — drag to reorder. Trigger from the Dashboard Overlays card to display on the TV. +6. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. +7. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. +8. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template +9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV. --- @@ -191,9 +197,9 @@ Open **http://localhost:8054/login** and register. This creates your admin accou | URL | Description | |-----|-------------| -| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes | +| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes, trigger TV overlays | | `/logs` | Browse timer and strike event history and manual notes; filter by child and date | -| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | +| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | ### Super Admin Views @@ -236,7 +242,7 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check | URL | Description | |-----|-------------| -| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay | +| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay, rules/expectations overlay | Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. @@ -279,6 +285,8 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events: | `break_resume` | Break timer resumed | `block_id`, `current_block_id` | | `break_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) | | `strikes_update` | Strike issued/cleared/midnight reset | `strikes` | +| `show_rules` | Rules/Expectations overlay triggered from Dashboard | `rules` (array of rule text strings) | +| `hide_rules` | Rules/Expectations overlay dismissed from Dashboard | — | **Notes:** diff --git a/backend/app/main.py b/backend/app/main.py index 868614c..20ff199 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from app.models.child import Child from app.models.subject import Subject from app.models.user import User from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard -from app.routers import morning_routine, break_activity, admin +from app.routers import morning_routine, break_activity, admin, rules, overlays from app.websocket.manager import manager settings = get_settings() @@ -113,6 +113,8 @@ app.include_router(morning_routine.router) app.include_router(break_activity.router) app.include_router(dashboard.router) app.include_router(admin.router) +app.include_router(rules.router) +app.include_router(overlays.router) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 17f2411..a5a8fe1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.activity import ActivityLog from app.models.morning_routine import MorningRoutineItem from app.models.break_activity import BreakActivityItem from app.models.strike import StrikeEvent +from app.models.rule import RuleItem __all__ = [ "Base", @@ -26,4 +27,5 @@ __all__ = [ "MorningRoutineItem", "BreakActivityItem", "StrikeEvent", + "RuleItem", ] diff --git a/backend/app/models/rule.py b/backend/app/models/rule.py new file mode 100644 index 0000000..b12eacb --- /dev/null +++ b/backend/app/models/rule.py @@ -0,0 +1,15 @@ +from sqlalchemy import ForeignKey, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class RuleItem(Base): + __tablename__ = "rule_items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + text: Mapped[str] = mapped_column(Text, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0) + + user: Mapped["User"] = relationship("User") # noqa: F821 diff --git a/backend/app/routers/overlays.py b/backend/app/routers/overlays.py new file mode 100644 index 0000000..330b765 --- /dev/null +++ b/backend/app/routers/overlays.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.dependencies import get_db, get_current_user +from app.models.child import Child +from app.models.rule import RuleItem +from app.models.user import User +from app.websocket.manager import manager + +router = APIRouter(prefix="/api/overlays", tags=["overlays"]) + + +class ChildTarget(BaseModel): + child_id: int + + +async def _verify_child(child_id: int, user: User, db: AsyncSession) -> Child: + result = await db.execute( + select(Child).where(Child.id == child_id, Child.user_id == user.id) + ) + child = result.scalar_one_or_none() + if not child: + raise HTTPException(status_code=404, detail="Child not found") + return child + + +@router.post("/rules/show") +async def show_rules_overlay( + body: ChildTarget, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + child = await _verify_child(body.child_id, current_user, db) + + rules_result = await db.execute( + select(RuleItem) + .where(RuleItem.user_id == current_user.id) + .order_by(RuleItem.order_index, RuleItem.id) + ) + rules = [item.text for item in rules_result.scalars().all()] + + await manager.broadcast(child.id, {"event": "show_rules", "rules": rules}) + return {"ok": True} + + +@router.post("/rules/hide") +async def hide_rules_overlay( + body: ChildTarget, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + child = await _verify_child(body.child_id, current_user, db) + await manager.broadcast(child.id, {"event": "hide_rules"}) + return {"ok": True} diff --git a/backend/app/routers/rules.py b/backend/app/routers/rules.py new file mode 100644 index 0000000..c421420 --- /dev/null +++ b/backend/app/routers/rules.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.dependencies import get_db, get_current_user +from app.models.rule import RuleItem +from app.models.user import User + +router = APIRouter(prefix="/api/rules", tags=["rules"]) + + +class RuleItemOut(BaseModel): + id: int + text: str + order_index: int + model_config = {"from_attributes": True} + + +class RuleItemCreate(BaseModel): + text: str + order_index: int = 0 + + +class RuleItemUpdate(BaseModel): + text: str | None = None + order_index: int | None = None + + +class ReorderEntry(BaseModel): + id: int + order_index: int + + +@router.get("", response_model=list[RuleItemOut]) +async def list_rules( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(RuleItem) + .where(RuleItem.user_id == current_user.id) + .order_by(RuleItem.order_index, RuleItem.id) + ) + return result.scalars().all() + + +@router.post("", response_model=RuleItemOut, status_code=status.HTTP_201_CREATED) +async def create_rule( + body: RuleItemCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + item = RuleItem(user_id=current_user.id, text=body.text, order_index=body.order_index) + db.add(item) + await db.commit() + await db.refresh(item) + return item + + +@router.patch("/{item_id}", response_model=RuleItemOut) +async def update_rule( + item_id: int, + body: RuleItemUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(RuleItem).where( + RuleItem.id == item_id, + RuleItem.user_id == current_user.id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Rule not found") + if body.text is not None: + item.text = body.text + if body.order_index is not None: + item.order_index = body.order_index + await db.commit() + await db.refresh(item) + return item + + +@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT) +async def reorder_rules( + body: list[ReorderEntry], + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ids = [e.id for e in body] + result = await db.execute( + select(RuleItem).where( + RuleItem.id.in_(ids), + RuleItem.user_id == current_user.id, + ) + ) + items = {item.id: item for item in result.scalars().all()} + for entry in body: + if entry.id in items: + items[entry.id].order_index = entry.order_index + await db.commit() + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_rule( + item_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(RuleItem).where( + RuleItem.id == item_id, + RuleItem.user_id == current_user.id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Rule not found") + await db.delete(item) + await db.commit() diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 60451de..83f254e 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -18,6 +18,8 @@ export const useScheduleStore = defineStore('schedule', () => { const breakStartedAt = ref(null) // Date.now() ms when break counting started const breakElapsedOffset = ref(0) // break seconds already elapsed const breakElapsedCache = ref({}) // blockId → total break elapsed seconds + const showRulesOverlay = ref(false) // whether the rules overlay is visible on TV + const rulesOverlayItems = ref([]) // list of rule text strings to display const currentBlock = computed(() => session.value?.current_block_id @@ -80,6 +82,15 @@ export const useScheduleStore = defineStore('schedule', () => { } function applyWsEvent(event) { + if (event.event === 'show_rules') { + rulesOverlayItems.value = event.rules || [] + showRulesOverlay.value = true + return + } + if (event.event === 'hide_rules') { + showRulesOverlay.value = false + return + } if (event.event === 'session_update') { applySnapshot(event) return @@ -394,6 +405,8 @@ export const useScheduleStore = defineStore('schedule', () => { breakStartedAt, breakElapsedOffset, breakElapsedCache, + showRulesOverlay, + rulesOverlayItems, currentBlock, progressPercent, applySnapshot, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 03984d0..85a221d 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -196,6 +196,47 @@ + +
+
+

Rules & Expectations

+
+
+

These rules are shown as a full-screen overlay on the TV when you trigger it from the Dashboard.

+
+ +
No rules added yet.
+
+
+ + +
+
+
+
@@ -636,6 +677,70 @@ async function deleteBreakItem(id) { await loadBreakActivities() } +// Rules & Expectations +const rules = ref([]) +const newRuleText = ref('') +const editingRuleItem = ref(null) +const ruleDragStartIndex = ref(null) +const ruleDragOverIndex = ref(null) + +async function loadRules() { + const res = await api.get('/api/rules') + rules.value = res.data +} + +async function addRuleItem() { + await api.post('/api/rules', { + text: newRuleText.value, + order_index: rules.value.length, + }) + newRuleText.value = '' + await loadRules() +} + +function startEditRuleItem(item) { + editingRuleItem.value = { ...item } +} + +async function saveRuleItem() { + await api.patch(`/api/rules/${editingRuleItem.value.id}`, { + text: editingRuleItem.value.text, + }) + editingRuleItem.value = null + await loadRules() +} + +async function deleteRuleItem(id) { + await api.delete(`/api/rules/${id}`) + await loadRules() +} + +function ruleDragStart(index) { + ruleDragStartIndex.value = index +} + +function ruleDragOver(index) { + ruleDragOverIndex.value = index +} + +function ruleDrop() { + const from = ruleDragStartIndex.value + const to = ruleDragOverIndex.value + if (from === null || to === null || from === to) return + const arr = [...rules.value] + const [removed] = arr.splice(from, 1) + arr.splice(to, 0, removed) + rules.value = arr +} + +async function ruleDragEnd() { + ruleDragStartIndex.value = null + ruleDragOverIndex.value = null + // Persist new order + const payload = rules.value.map((item, index) => ({ id: item.id, order_index: index })) + await api.put('/api/rules/reorder', payload) +} + // Schedules const templates = ref([]) const showCreateForm = ref(false) @@ -733,7 +838,7 @@ async function deleteBlock(templateId, blockId) { onMounted(async () => { await childrenStore.fetchChildren() - await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities()]) + await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities(), loadRules()]) selectedTimezone.value = authStore.timezone }) @@ -908,6 +1013,20 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin .option-add-row .option-input { background: #1e293b; } .routine-hint { font-size: 0.82rem; color: #64748b; margin-bottom: 1rem; } + +.drag-handle { + color: #475569; + cursor: grab; + font-size: 1.1rem; + flex-shrink: 0; + padding: 0 0.25rem; + user-select: none; +} +.drag-handle:active { cursor: grabbing; } + +.rule-drag-row { cursor: default; } +.rule-drag-row[draggable="true"]:hover { background: #243448; } +.rule-drag-over { border: 1px dashed #4f46e5 !important; background: #1a1a3a !important; } .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; } .support-card { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 9f54128..0adc3da 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -12,37 +12,60 @@
- -
-
3 Strikes
-
-
-
- {{ child.name }} -
- + + +
+ +
+
TV Dashboard
+

Open this on the living room TV for the full-screen view.

+ + Open TV View → + +
+ + +
+
3 Strikes
+
+
+
+ {{ child.name }} +
+ +
+
No children added yet.
+
+
+ + +
+
Overlays
+
+
-
No children added yet.
- -
-
TV Dashboard
-

Open this on the living room TV for the full-screen view.

- - Open TV View → - -
- + +
Today's Session
@@ -171,6 +194,7 @@ />
+
@@ -276,7 +300,7 @@ let wsDisconnect = null async function loadDashboard() { if (!activeChild.value) return - await scheduleStore.fetchDashboard(activeChild.value.id) + await scheduleStore.fetchDashboard(activeChild.value.tv_token) // Load templates for start dialog const res = await api.get('/api/schedules') @@ -284,7 +308,7 @@ async function loadDashboard() { // WS subscription if (wsDisconnect) wsDisconnect() - const { disconnect } = useWebSocket(activeChild.value.id, (msg) => { + const { disconnect } = useWebSocket(activeChild.value.tv_token, (msg) => { scheduleStore.applyWsEvent(msg) }) wsDisconnect = disconnect @@ -346,6 +370,14 @@ const estimatedFinishTime = computed(() => { return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}` }) +async function toggleRulesOverlay() { + if (!activeChild.value) return + const endpoint = scheduleStore.showRulesOverlay + ? '/api/overlays/rules/hide' + : '/api/overlays/rules/show' + await api.post(endpoint, { child_id: activeChild.value.id }) +} + function selectBlock(block) { if (!scheduleStore.session) return // Clicking the current block does nothing — use Start/Pause/Resume buttons @@ -368,11 +400,31 @@ watch(activeChild, loadDashboard) h1 { font-size: 1.75rem; font-weight: 700; } .dashboard-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + display: flex; + flex-direction: column; gap: 1.5rem; } +.top-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.bottom-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 700px) { + .top-row, + .bottom-row { + grid-template-columns: 1fr; + } +} + .card { background: #1e293b; border-radius: 1rem; @@ -477,8 +529,6 @@ h1 { font-size: 1.75rem; font-weight: 700; } .block-list { display: flex; flex-direction: column; gap: 0.5rem; } -.strikes-card { grid-column: span 1; } - .strikes-list { display: flex; flex-direction: column; gap: 0.6rem; } .strikes-row { @@ -516,8 +566,46 @@ h1 { font-size: 1.75rem; font-weight: 700; } color: #fca5a5; } -.tv-card { grid-column: span 1; } .tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; } +.overlays-grid { display: flex; flex-wrap: wrap; gap: 0.75rem; } + +.overlay-btn { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.75rem 1.25rem; + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.75rem; + color: #94a3b8; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} +.overlay-btn:hover:not(:disabled) { background: #334155; border-color: #475569; color: #f1f5f9; } +.overlay-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.overlay-btn-active { + background: #1e1b4b; + border-color: #6366f1; + color: #a5b4fc; +} +.overlay-btn-active:hover:not(:disabled) { background: #312e81; } +.overlay-btn-icon { font-size: 1.2rem; } +.overlay-live-badge { + font-size: 0.65rem; + font-weight: 700; + background: #6366f1; + color: #fff; + padding: 0.1rem 0.4rem; + border-radius: 999px; + letter-spacing: 0.06em; + animation: pulse-badge 1.5s ease-in-out infinite; +} +@keyframes pulse-badge { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} .btn-primary { display: inline-block; diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index 40ec065..346c05a 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -163,6 +163,24 @@
+ + + +
+
+
+ 📋 + Rules & Expectations +
+
    +
  1. + {{ rule }} +
  2. +
+
Tap anywhere to dismiss
+
+
+
@@ -612,4 +630,88 @@ onMounted(async () => { .tv-alert-leave-active { transition: all 0.3s ease; } .tv-alert-enter-from { opacity: 0; transform: scale(0.9); } .tv-alert-leave-to { opacity: 0; transform: scale(1.05); } + +/* Rules/Expectations full-screen overlay */ +.tv-rules-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: pointer; +} +.tv-rules-box { + background: #0f172a; + border: 3px solid #10b981; + border-radius: 2rem; + padding: 3.5rem 4.5rem; + max-width: 800px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 0 80px rgba(16, 185, 129, 0.3); + display: flex; + flex-direction: column; + gap: 2rem; +} +.tv-rules-header { + display: flex; + align-items: center; + gap: 1rem; + justify-content: center; +} +.tv-rules-icon { font-size: 3rem; } +.tv-rules-title { + font-size: 2.5rem; + font-weight: 800; + color: #34d399; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.tv-rules-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1.25rem; + counter-reset: rules-counter; +} +.tv-rules-item { + counter-increment: rules-counter; + display: flex; + align-items: flex-start; + gap: 1.25rem; + font-size: 2rem; + font-weight: 500; + color: #f1f5f9; + line-height: 1.3; + padding-bottom: 1.25rem; + border-bottom: 1px solid #1e293b; +} +.tv-rules-item:last-child { border-bottom: none; padding-bottom: 0; } +.tv-rules-item::before { + content: counter(rules-counter); + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + background: #064e3b; + border: 2px solid #10b981; + border-radius: 50%; + font-size: 1.2rem; + font-weight: 800; + color: #34d399; + flex-shrink: 0; + margin-top: 0.1rem; +} +.tv-rules-dismiss-hint { + font-size: 1rem; + color: #475569; + text-align: center; + margin-top: 0.5rem; +}