Add Rules & Expectations feature with TV overlay and dashboard layout updates

- Add Rules & Expectations admin section with drag-to-reorder, add/edit/delete
- Add Overlays card to Dashboard with Rules/Expectations toggle button (LIVE badge when active)
- Add full-screen rules overlay on TV view (green theme, numbered list, tap to dismiss)
- Backend: new RuleItem model, /api/rules CRUD + bulk reorder, /api/overlays WS broadcast endpoints
- Schedule store handles show_rules / hide_rules WebSocket events
- Rearrange Dashboard top row: TV Dashboard | 3 Strikes | Overlays (3-col, mobile stacks)
- Put Today's Session and Today's Schedule side-by-side at 50/50 width (mobile stacks)
- Update README with all new features, setup steps, WS events, and project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 07:21:50 -07:00
parent 956df11f49
commit d724262e27
10 changed files with 569 additions and 42 deletions

View File

@@ -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:**

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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}

View File

@@ -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()

View File

@@ -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,

View File

@@ -196,6 +196,47 @@
</div>
</section>
<!-- Rules & Expectations section -->
<section class="section">
<div class="section-header">
<h2>Rules &amp; Expectations</h2>
</div>
<div class="card">
<p class="routine-hint">These rules are shown as a full-screen overlay on the TV when you trigger it from the Dashboard.</p>
<div class="option-list">
<template v-for="(item, index) in rules" :key="item.id">
<div v-if="editingRuleItem && editingRuleItem.id === item.id" class="option-edit-row">
<input v-model="editingRuleItem.text" class="option-input" @keyup.enter="saveRuleItem" />
<button class="btn-sm btn-primary" @click="saveRuleItem">Save</button>
<button class="btn-sm" @click="editingRuleItem = null">Cancel</button>
</div>
<div
v-else
class="option-row rule-drag-row"
draggable="true"
@dragstart="ruleDragStart(index)"
@dragover.prevent="ruleDragOver(index)"
@drop="ruleDrop"
@dragend="ruleDragEnd"
:class="{ 'rule-drag-over': ruleDragOverIndex === index && ruleDragStartIndex !== index }"
>
<span class="drag-handle" title="Drag to reorder">⠿</span>
<span class="option-text">{{ item.text }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditRuleItem(item)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteRuleItem(item.id)">✕</button>
</div>
</div>
</template>
<div v-if="rules.length === 0" class="empty-small">No rules added yet.</div>
</div>
<form class="option-add-row" style="margin-top: 0.75rem" @submit.prevent="addRuleItem">
<input v-model="newRuleText" placeholder="Add a rule or expectation..." class="option-input" required />
<button type="submit" class="btn-primary btn-sm">Add</button>
</form>
</div>
</section>
<!-- Schedules section -->
<section class="section">
<div class="section-header">
@@ -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
})
</script>
@@ -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 {

View File

@@ -12,6 +12,18 @@
</div>
<div v-else class="dashboard-grid">
<!-- Top row: TV Dashboard · 3 Strikes · Overlays -->
<div class="top-row">
<!-- TV Link -->
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary">
Open TV View
</a>
</div>
<!-- 3 Strikes -->
<div class="card strikes-card">
<div class="card-title">3 Strikes</div>
@@ -34,15 +46,26 @@
</div>
</div>
<!-- TV Link -->
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary">
Open TV View
</a>
<!-- Overlays -->
<div class="card overlays-card">
<div class="card-title">Overlays</div>
<div class="overlays-grid">
<button
class="overlay-btn"
:class="{ 'overlay-btn-active': scheduleStore.showRulesOverlay }"
@click="toggleRulesOverlay"
:disabled="!activeChild"
>
<span class="overlay-btn-icon">📋</span>
<span>Rules/Expectations</span>
<span v-if="scheduleStore.showRulesOverlay" class="overlay-live-badge">LIVE</span>
</button>
</div>
</div>
</div>
<!-- Session + Schedule row -->
<div class="bottom-row">
<!-- Today's session card -->
<div class="card session-card">
<div class="card-title">Today's Session</div>
@@ -171,6 +194,7 @@
/>
</div>
</div>
</div><!-- end bottom-row -->
</div>
@@ -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;

View File

@@ -163,6 +163,24 @@
</div>
</div>
</transition>
<!-- Rules/Expectations full-screen overlay -->
<transition name="tv-alert">
<div v-if="scheduleStore.showRulesOverlay" class="tv-rules-overlay" @click="scheduleStore.showRulesOverlay = false">
<div class="tv-rules-box">
<div class="tv-rules-header">
<span class="tv-rules-icon">📋</span>
<span class="tv-rules-title">Rules &amp; Expectations</span>
</div>
<ol class="tv-rules-list">
<li v-for="(rule, i) in scheduleStore.rulesOverlayItems" :key="i" class="tv-rules-item">
{{ rule }}
</li>
</ol>
<div class="tv-rules-dismiss-hint">Tap anywhere to dismiss</div>
</div>
</div>
</transition>
</div>
</template>
@@ -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;
}
</style>