diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index 6106744..0000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "mcpServers": {} -} - diff --git a/.gitignore b/.gitignore index eed8b87..f75b610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.gemini .worktrees/ docs/ diff --git a/docs/design_handoff/README.md b/docs/design_handoff/README.md deleted file mode 100644 index 2748f5c..0000000 --- a/docs/design_handoff/README.md +++ /dev/null @@ -1,325 +0,0 @@ -# Handoff: Tutormanager — Anwesenheit & Notizen - -## Overview - -Tutormanager is an attendance + per-student notes tool for tutorien. The flagship use case is the FP-Tutorium (~19 students, Thursdays). Tutors run a short check-in routine at the start of each Projektstunde: open a slot, project a check-in URL on the beamer, watch students fill the seat map from their phones/laptops, lock the slot, and use the rest of the hour with a live seat plan that lets them jot per-student observations as they go. - -The full architecture is described in `SPEC.md` (your existing approved spec). This handoff covers **only the frontend**. The HTML prototypes in this folder are **design references** — pixel-accurate mocks of the intended look and behaviour. The task is to **recreate them in SvelteKit** (`adapter-static`, SPA) per the approved stack, talking to the Rust/Axum backend defined in the spec. - -## About the Design Files - -The files in this bundle are design references created with React + inline JSX in static HTML. Don't ship them. Re-implement the same screens as Svelte components in the SvelteKit app, using your own component split and your own state stores. The HTML/CSS choices (paper background, ruled notebook, oxblood accent, Source Serif 4 + Inter + JetBrains Mono + Caveat) should transfer 1:1. - -Open `Tutormanager.html` in a browser to view the canvas with all screens. `styles.css` holds the design tokens. - -## Fidelity - -**High-fidelity.** Colors, typography, spacing, and component states are final. Recreate pixel-perfectly in Svelte using the design tokens listed below. - ---- - -## Design Tokens - -All in `styles.css` as CSS custom properties. Bring them in verbatim (e.g. as a `app.css` imported in `+layout.svelte`). - -### Colors - -| Token | Value | Usage | -|---|---|---| -| `--paper` | `#f4efe6` | App background | -| `--paper-2` | `#ebe4d6` | Subtle alt rows, avatar bg | -| `--paper-3` | `#ded4c0` | Slightly stronger paper | -| `--rule` | `#c9bfa9` | Borders, dividers | -| `--rule-soft` | `#d9d0bb` | Softer dividers | -| `--ink` | `#1f1b16` | Primary text, primary buttons | -| `--ink-2` | `#3a342b` | Body text | -| `--ink-3` | `#6b6356` | Secondary / small | -| `--ink-4` | `#968b7a` | Tertiary, placeholders | -| `--accent` | `#8a2c1f` | Oxblood — accents, "absent", lock, stamps | -| `--accent-soft` | `#c66a5b` | — | -| `--highlight` | `#f1d36a` | Highlighter yellow | -| `--highlight-soft` | `#f5e3a4` | Marker underline fill | -| `--green` | `#4a6b3a` | "anwesend" / present | -| `--red` | `#8a2c1f` | "fehlt" / locked | -| `--amber` | `#b07d2a` | "offen" / open slot | - -Card surface is `#fbf7ee` (slightly lighter than paper). Seat map background is `#f7f1e3`. Tables in seat map are `#e8dec5` with `var(--ink-2)` border. - -### Typography - -| Token | Stack | -|---|---| -| `--serif` | `"Source Serif 4", "Source Serif Pro", "EB Garamond", Georgia, serif` | -| `--sans` | `"Inter", "Helvetica Neue", Helvetica, Arial, sans-serif` | -| `--mono` | `"JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace` | -| Marginalia | `"Caveat"` (used only for handwritten notes — see `.handwritten`) | - -Load via Google Fonts (single ``): -``` -Source Serif 4 (300–700, italic), Inter (400/500/600), JetBrains Mono (400/500/600), Caveat (400/500/600) -``` - -Type scale (utility classes in `styles.css`): - -- `.h1` — Source Serif 4, 44/1.05, weight 500, letter-spacing -0.02em -- `.h2` — Source Serif 4, 28/1.15, weight 500, letter-spacing -0.01em -- `.h3` — Source Serif 4, 20/1.2, weight 500 -- `.eyebrow` — JetBrains Mono, 11px, uppercase, 0.14em tracking, `--ink-3` -- `.body` — Inter, 14/1.5 -- `.small` — Inter, 12, `--ink-3` -- `.tiny` — Inter, 11, `--ink-3` - -### Spacing - -8px-aligned grid. Common values seen in the design: 4, 6, 8, 10, 12, 14, 16, 18, 22, 24, 28, 32, 36. - -### Radii - -- 3–4px — most cards, inputs, buttons (paper feel — keep tight) -- 999px — pills -- 50% — avatars, seat circles - -### Shadows - -Avoid heavy shadows. Use a `1px 0 rgba(0,0,0,.03)` underline on cards. The seat-map container uses a subtle inset border instead of a box shadow. - -### Special textures - -- `.paper-bg` — paper grain via inline SVG noise + 2 radial highlights. Applied to app root and large surfaces. -- `.ruled` — repeating-linear-gradient producing 28px-spaced horizontal rules. Used inside the note textarea so handwritten text sits on lines. -- `.marker` — gradient that paints `--highlight-soft` over the lower 32% of inline text — looks like a hand-drawn highlighter stroke. Use sparingly on h1/h2 keywords (one word per heading). -- `.stamp` — rotated -4°, oxblood border + tint, monospace caps. Used for "PRÄSENT". -- `.handwritten` — Caveat in oxblood, 18px. For marginalia / sketch annotations only. -- `UnderlineStroke` — a tiny SVG hand-drawn underline beneath section titles. See `shared.jsx`. - ---- - -## Information Architecture - -``` -/ → redirect to /admin or /admin/login -/admin/login → Login (anonymous) -/admin → Dashboard (slots overview) -/admin/live/:slotId → Hero · live seat map + notes panel -/admin/attendance → Per-student / per-week matrix + export -/admin/rooms → Layout editor (rooms list / canvas / props) -/admin/rooms/:roomId → Same, with active room -/admin/students → Students CRUD -/s/:code → Student check-in (mobile + desktop responsive) -``` - -The student check-in screen MUST be responsive: phone gets a single-column compact layout; ≥ 900px viewport gets the two-column laptop layout (map left, side panel right). Both are in this bundle. - ---- - -## Screens - -### 1. Login (`/admin/login`) - -Centered 420px-wide card on `.paper-bg`. Brand wordmark "Tutor·manager" with oxblood `·`. Card title is `.h2` "Willkommen zurück" with an `UnderlineStroke` (110px wide). Two stacked inputs (E-Mail, Passwort), full-width primary button "Anmelden". Below the card a small Caveat marginalia "~ Donnerstags ab 14 Uhr ~". - -### 2. Tutor shell - -Two columns: 220px sidebar + main content. - -**Sidebar** (`background: rgba(0,0,0,0.015)`, right border `--rule`): -- Brand wordmark + version -- "Kurs" card (course name + semester + weekday) -- Nav list: Dashboard / Live · Sitzplan / Anwesenheit / Räume / Studierende. Active item: 4px oxblood dot + `rgba(31,27,22,0.08)` background. -- Bottom: avatar circle + tutor name + email. - -### 3. Dashboard (`/admin`) - -- Header: eyebrow "Dashboard", `.h1` "Diese Woche, **Woche 04**" (the week number gets `.marker`), one-line subtitle. Right side: "Export" (ghost) + "Neuer Slot" (primary) buttons. -- 4 stat cards in a row: "Anwesend gerade 14 / 19", "Ø Anwesenheit 84%", "Bonus vergeben 186 Punkte", "Offene Notizen 7". Big numbers in serif 32px, accent color when meaningful. -- Slots table: columns Woche / Datum / Zeit / Raum / Status / Code / Eingecheckt / Aktionen. Status pills (open / closed / locked). Eingecheckt cell shows "n / total" + a 48×4px progress bar. Per-row primary action depends on status (Sperren / Öffnen / Anzeigen). - -### 4. Live seat map + notes (HERO — `/admin/live/:slotId`) - -Two-column grid: `1fr 380px`. - -**Left column — seat map:** -- Header row: "Sitzplan" (`.h3` + UnderlineStroke 70px) on the left; legend dots on the right (anwesend / frei / ausgewählt). -- The seat map itself (see "Seat map component" below) at scale 0.85. -- Tally row at bottom: 3 stat blocks (Anwesend `n/total`, Fehlt n, Bonus heute "+n Punkte") + spacer + ghost button "Manuell eintragen". - -**Right column — roster + note editor (a card):** -- Top: "Studierende" with present/absent count. -- Scrollable roster (max-height ~220px). Each row: 22px ink avatar circle, name, oxblood dot if has note, monospace check-in time (or "—" for absent). Selected row: `rgba(31,27,22,0.06)` bg + 3px ink left border. Absent rows: 0.55 opacity, dashed avatar border, strikethrough name. -- Bottom — note editor on `#fbf7ee`: - - 32px ink avatar + selected student name (`.h3`) + "Sitzplatz {seat} · seit {time}" + "Präsent" stamp on the right. - - "Notiz · Woche 04" eyebrow. - - Textarea with `.ruled` background, Source Serif 4 15/28px, no border. Placeholder "Beobachtungen für diese Woche…". - - Quick-tag chips below: "+ aktiv beteiligt", "+ stille:r Kämpfer:in", "+ verstanden ✓", "+ nochmal aufgreifen", "+ Rückfrage offen", "+ elegante Lösung". - - Footer: "Auto-gespeichert · 14:23" left, "Notizen vergangener Wochen ↗" link right. - -Page header above the grid: -- Left side: eyebrow "Tutor:innen-Ansicht · Live", title "Woche 04 · **Donnerstag, 30. April 2026**" (date with `.marker`), subtitle line: course / room / time. -- Right side: monospace check-in code "K7QJ-MX2P" with the URL beneath, status pill (offen), Kopieren / Sperren buttons. - -### 5. Anwesenheit (`/admin/attendance`) - -- Header `.h1` "Kursmatrix · **SS 2026**". Right: 3 ghost export buttons (CSV / Markdown / SQLite Backup). -- Card with tab bar: "Pro Studierende:r" (active) / "Pro Woche" / "Notizen". -- Matrix table: # / Studierende:r / W01 / W02 / W03 / W04 / Anwesend / Bonus / arrow. Present cell is a 22px square with green check on `rgba(74,107,58,0.14)`. Absent: em-dash. Bonus column shows a single 24px oxblood circled check (no number — only "got bonus or not"). - -### 6. Räume — Layout-Editor (`/admin/rooms/:roomId`) - -Three-column grid: 210px / 1fr / 240px. - -- **Left**: rooms list (selectable, oxblood left border on active), "+ Neuer Raum" button at bottom. -- **Center** — toolbar + canvas + status bar: - - Toolbar: 6 tool buttons (Auswählen / Sitz / Tisch / Tür / Fenster / Lücke), each with a small monospace glyph + label. Active tool is filled ink. Right side: zoom controls (− 78% +), "Raster: 24px". - - Canvas: `#efe6d2` background with rulers along top (px ticks) and left, a centred seat map at scale 0.78. Selected element shows a rotating dashed oxblood ring + 4 small white drag-handle squares. Two Caveat marginalia annotations. - - Bottom status bar: element counts, auto-save timestamp. -- **Right**: stacked cards. - - "Auswahl" card: shows kind + id ("Sitz T2-3"), then editable fields (Bezeichnung, Tisch, X / Y in px, ∅ / Rotation). Below: "Aktionen" — Duplizieren (⌘D), An Tisch ausrichten, Löschen (⌫, oxblood text). - - "Ebenen" card: tree-like list of layers — walls, 4 tables with their seats, podium, beamer, door, window. Selected layer (T2 group) gets oxblood text + tinted bg. - -### 7. Studierende (`/admin/students`) - -Header with title + search input + "CSV importieren" + "Hinzufügen". Card with table: # / Name (avatar + name) / Anwesend (n/4) / Bonus (single circled check or em-dash) / Notizen (oxblood dot + count) / Letzte Sitzung / arrow. - -### 8. Student check-in — phone (`/s/:code`, viewport < 900px) - -Four states (each on `.paper-bg`, ~360×760 viewport): - -1. **Name picker**: eyebrow "Check-in · K7QJ-MX2P", title "Wer bist du?", search input, scrollable name list (avatar + name, divider lines). Footer hint "Nicht in der Liste? Sprich die Tutor:in an." -2. **Seat picker**: "Hallo, Carla 👋" eyebrow, title "Wähle deinen Sitz", scaled seat map (0.46), legend, info card "Du kannst deinen Sitz wechseln, solange der Slot offen ist." -3. **Confirmed**: title "Du sitzt auf **T1-3**" (T1-3 with `.marker`), "Präsent" stamp top-right, scaled map showing only own seat highlighted oxblood, status card with "OFFEN" pill + lock-time hint, ghost "Sitz wechseln" button. -4. **Locked / read-only**: title "Anwesenheit erfasst", scaled map (own seat still oxblood), info card with lock icon + "Check-in für diesen Slot ist geschlossen. Bonus wurde gutgeschrieben.", footer with next session date. - -### 9. Student check-in — laptop (`/s/:code`, viewport ≥ 900px) - -Three states: - -1. **Name picker**: centered 560px column. Header eyebrow + .h1 "Wer bist du?" + subtitle. Card with autofocused search and a 2-column grid of name buttons. Footer keyboard-shortcut tip. -2. **Seat picker**: two-column `1fr 360px`. Left: large seat map (scale 0.78) + legend below. Right side panel (subtle bg, left border): "Sitzung" block (course / date+time / room), "Eingecheckt als" with avatar + "wechseln" link, "Hinweise" bullet list, bottom yellow-tinted card "Bonus +3 Punkte sobald du einen Sitz wählst." -3. **Confirmed (with season)**: same two-column. Left: header "Du sitzt auf **T1-3**" + stamp, large map with own seat highlighted, two ghost buttons (Sitz wechseln / Drucken). Right side: "Slot-Status" with open pill, "Deine Saison" — 4-row weekly history list (W01–W04, date, seat code, green check or em-dash; current week in oxblood), oxblood-tinted card "Bonus gesamt 9 / 12 Punkte · 3 von 4 Tutorien besucht". - ---- - -## Components to build - -| Svelte component | Notes | -|---|---| -| `` | See `.pill` and `.pill.{state}` in `styles.css`. | -| `` | Inline SVG path — see `shared.jsx`. | -| `Präsent` | `.stamp` class. | -| `` / `

` / `

` / `

` | Or just utility classes. | -| `` | Stat block in serif. | -| `` | Card variant for the dashboard. | -| `` | Labelled input with optional unit suffix in mono. | -| `` | Sidebar + main slot. | -| `` | The big one — see below. | -| `` | Roster + ruled textarea + tag chips. | -| `` | Inline span with `.marker`. | - -### `` — the central component - -Top-down floor plan, absolutely-positioned layers inside a fixed-size design space (760×460 px) that gets transform-scaled by the `scale` prop. - -**Static structural layers** (drawn in this order): -1. Inner ruled grid (24px graph paper, very low alpha). -2. Walls — 2px ink-2 border rounded 2px. -3. Window — 6×220px tall blue-tinted strip on the left wall with a horizontal split line. Mono "Fenster" label rotated -90°. -4. Door — gap in bottom wall (70×4px) covered with paper colour, an SVG door arc (`stroke-dasharray="2 2"`), and a "Tür" label. -5. Beamer — small black bar at top. -6. Podium / Pult — 190×38 ruled rectangle, mono caps "Pult · Tutor:in". -7. Tables — 4 rectangles (200×70px) at fixed coords. Filled `#e8dec5` with ink-2 border + italic serif label "T1"–"T4" centred at 0.35 alpha. - -**Seats** — generated from tables: 5 per table. -- Top edge (y = table.y − 22): 2 seats at x = table.x + table.w*{0.28, 0.72}. -- Bottom edge (y = table.y + table.h + 22): 2 seats, same x ratios. -- Head (short edge, x = table.x + table.w + 26): 1 seat at table.y + table.h/2. - -Seat = 36px circle button, 1.5px border. Per `variant`: - -- **tutor**: occupied seat shows ink avatar with student initials in white. Selected: filled ink + 3px highlight-soft outer ring. Hover: `#e0d4b6` fill. Free: paper bg + ink-4 border. -- **student**: occupied = `#d6cdb5` (no name shown — privacy). Free = paper bg + ink-2 border. Own seat (after pick) = oxblood fill, white star ★ glyph. -- **student-self** (read-only): same colours, no clicks, own seat = oxblood + ★, others greyed if occupied. - -Compass mark in bottom-right (mono "N" + small SVG arrow). - -The layout JSON the backend stores must round-trip these elements: `[{id, label, x, y, width, height, type}]` with `type ∈ "seat" | "table" | "gap" | "door" | "window" | "wall" | "beamer" | "podium"`. The component takes that JSON as input — don't hardcode the room. - ---- - -## Interactions & state - -- **Slot status transitions** (tutor): `closed → open → locked`. Opening generates the 8-char code (server-side per spec). Closed/locked rows show no code. The UI must atomically reflect "open ⇒ has code". -- **Seat click (tutor live view)**: selects that student in the roster; note editor scrolls/loads. Double-click could open the per-student timeline. -- **Note editor**: debounced auto-save (~600ms) → PUT note. Show "Auto-gespeichert · HH:MM" timestamp on success. -- **Quick tag chips**: on click, append the tag (with leading newline if note non-empty) to the note text, then save. -- **Student seat pick**: optimistic UI; on HTTP 409 ("seat taken"), revert and toast "Platz schon vergeben — bitte einen anderen wählen." -- **Seat change**: while `open`, click another free seat → confirmation toast → previous seat goes free immediately. -- **Locked**: pointer-events: none on seats; only own seat remains oxblood-highlighted. -- **Live polling**: poll the slot every 5–8s while open to refresh assignments and check-in count. -- **Marker animation**: the selection ring in the room editor uses `@keyframes spin 12s linear infinite`. Disable in `prefers-reduced-motion`. - ---- - -## State management - -Suggest one Svelte store per domain: - -- `auth` — JWT token, current tutor. -- `course` — currently selected course (and tutors' available courses). -- `slots` — slots for the active course/week. -- `liveSlot` — assignments + notes for the slot currently shown in the live view; reactive on poll. -- `room` — layout JSON of the room being edited; also handles selection + tool state for the editor. - -The check-in page is its own minimal app state: `{ code, slot, room, ownSeat, ownStudentId }`, no auth store. - ---- - -## Copy - -All UI is in **German**. Specific strings used in the design: - -- "Anwesend", "Fehlt", "Bonus heute", "Eingecheckt", "Sitzplan", "Studierende", "Notiz · Woche {n}". -- "Manuell eintragen", "Sperren", "Öffnen", "Anzeigen", "Kopieren", "Speichern". -- "Wer bist du?", "Wähle deinen Sitz", "Du sitzt auf {seat}", "Anwesenheit erfasst". -- "Nicht in der Liste? Sprich die Tutor:in an.", "Du kannst deinen Sitz wechseln, solange der Slot offen ist." -- Status pill labels: OFFEN / GESCHL. / GESPERRT / ANWESEND / FEHLT. -- Stamp: PRÄSENT. - -The sample course is "Funktionale Programmierung", semester "SS 2026", room "BC2 1.103", weekday "Donnerstag", tutorin "Lina Puchstein". Replace with real data from the backend. - ---- - -## Assets - -- **Fonts**: Source Serif 4, Inter, JetBrains Mono, Caveat — all from Google Fonts. -- **Icons**: tiny inline SVGs in `shared.jsx` (`Icon.check`, `Icon.x`, `Icon.lock`, `Icon.open`, `Icon.copy`, `Icon.edit`, `Icon.download`, `Icon.arrow`, `Icon.search`, `Icon.plus`). Re-use them as Svelte components or swap to Lucide if you prefer — they're trivial. -- **Paper grain**: inline SVG noise filter in `.paper-bg`. Copy verbatim. -- **No image assets**. - ---- - -## Files in this bundle - -| File | What's in it | -|---|---| -| `Tutormanager.html` | Entry point — design canvas hosting all artboards. Open in a browser. | -| `styles.css` | All design tokens + utility classes. Copy into the SvelteKit app. | -| `shared.jsx` | Sample course + students + room layout + `StatusPill`, `UnderlineStroke`, `Icon.*` | -| `seatmap.jsx` | `` and the `` hero — main reference. | -| `admin.jsx` | Tutor shell, Dashboard, Anwesenheit-Matrix, Studierende, Login. | -| `rooms.jsx` | Layout editor with toolbar, canvas, properties + layers panel. | -| `student.jsx` | Phone variants of the four student states. | -| `student-desktop.jsx` | Laptop variant of the three student states. | -| `design-canvas.jsx`, `browser-window.jsx`, `ios-frame.jsx` | Presentation chrome — for the design canvas only, do not port. | - -The design canvas grouping (sections / artboards) is purely a presentation device. The actual app has the route structure listed under "Information Architecture". - ---- - -## Implementation tips - -- Start with `styles.css` and the type/colour tokens. Wire fonts. -- Build `` early — it's the centrepiece and the only non-trivial component. -- The room editor's drag-and-drop is real work — the spec says layouts are stored as JSON. A DnD library (`svelte-dnd-action`) is fine; snap to a 24px grid. The visual shown is the *finished* state with one seat selected. -- Do **not** add visual flourishes (more icons, gradients, hero images). The look comes from restraint + the paper texture + the marker/stamp accents. -- Respect `prefers-reduced-motion` — disable the room-editor selection-ring spin and any hover scale. -- The handwritten Caveat marginalia is a flavour element. Keep them sparse — at most one per screen. diff --git a/docs/design_handoff/Tutormanager.html b/docs/design_handoff/Tutormanager.html deleted file mode 100644 index 93420cb..0000000 --- a/docs/design_handoff/Tutormanager.html +++ /dev/null @@ -1,192 +0,0 @@ - - - - -Tutormanager — Anwesenheit & Notizen - - - - - - - - -
- - - - - - - - - - - - - - - - - - diff --git a/docs/design_handoff/admin.jsx b/docs/design_handoff/admin.jsx deleted file mode 100644 index 25e85f0..0000000 --- a/docs/design_handoff/admin.jsx +++ /dev/null @@ -1,489 +0,0 @@ -// admin.jsx — Tutor admin shell + supporting screens (dashboard, attendance, rooms, students, login) - -const TutorShell = ({ active, onNav, children }) => { - const navItems = [ - { id: "dashboard", label: "Dashboard" }, - { id: "live", label: "Live · Sitzplan" }, - { id: "attendance", label: "Anwesenheit" }, - { id: "rooms", label: "Räume" }, - { id: "students", label: "Studierende" }, - ]; - return ( -
- {/* Sidebar */} - - -
{children}
-
- ); -}; - -// Dashboard ───────────────────────────────────────────────────────────── -const Dashboard = () => { - const slots = [ - { id: 1, week: 4, day: "Do, 30. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "open", code: "K7QJMX2P", checkedIn: 14, total: 19 }, - { id: 2, week: 4, day: "Do, 30. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "closed", code: null, checkedIn: 0, total: 19 }, - { id: 3, week: 3, day: "Do, 23. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "locked", code: "RX48ZF2K", checkedIn: 17, total: 19 }, - { id: 4, week: 3, day: "Do, 23. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "locked", code: "QM9WJ3VC", checkedIn: 12, total: 19 }, - { id: 5, week: 2, day: "Do, 16. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "locked", code: "B2HFNX9P", checkedIn: 18, total: 19 }, - { id: 6, week: 2, day: "Do, 16. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "locked", code: "VJ5KQM7R", checkedIn: 15, total: 19 }, - { id: 7, week: 1, day: "Do, 09. April", time: "14:00 – 15:00", room: "— (Papier)", status: "locked", code: null, checkedIn: 16, total: 19 }, - ]; - - return ( -
-
-
-
Dashboard
-
- Diese Woche, Woche 04 -
-
2 Slots geplant · 1 läuft gerade · 14 von 19 sind eingecheckt.
-
-
- - -
-
- - {/* Stat row */} -
- - - - -
- - {/* Slots table */} -
-
-
-
Slots
- -
-
Sortiert: neueste zuerst
-
- - - - - - - - - - - - - - - {slots.map((s, i) => ( - - - - - - - - - - - ))} - -
WocheDatumZeitRaumStatusCodeEingechecktAktionen
W{String(s.week).padStart(2, "0")}{s.day}{s.time}{s.room}{s.code ? {s.code} : } -
- {s.checkedIn} / {s.total} -
-
-
-
-
- {s.status === "open" && } - {s.status === "closed" && } - {s.status === "locked" && } -
-
-
- ); -}; - -const Th = ({ children, style }) => {children}; -const Td = ({ children, style, className }) => {children}; - -const StatCard = ({ label, value, suffix, hint, accent }) => ( -
-
{label}
-
- {value} - {suffix && {suffix}} -
- {hint &&
{hint}
} -
-); - -// Attendance matrix ───────────────────────────────────────────────────────────── -const AttendanceMatrix = () => { - const weeks = [1, 2, 3, 4]; - // deterministic random-ish presence - const presence = STUDENTS.map((s) => ({ - student: s, - weeks: weeks.map((w) => { - const hash = (s.id * 31 + w * 7) % 11; - return hash > 2; // ~80% - }), - })); - - return ( -
-
-
-
Anwesenheit
-
- Kursmatrix · SS 2026 -
-
Bonus = Anwesenheiten × 3 Punkte
-
-
- - - -
-
- -
-
-
Pro Studierende:r
-
Pro Woche
-
Notizen
-
- - - - - - - {weeks.map((w) => ( - - ))} - - - - - - - {presence.map((row, i) => { - const count = row.weeks.filter(Boolean).length; - return ( - - - - {row.weeks.map((p, wi) => ( - - ))} - - - - - ); - })} - -
#Studierende:rW{String(w).padStart(2, "0")}AnwesendBonus
{String(i + 1).padStart(2, "0")} -
- {row.student.initials} - {row.student.name} -
-
- {p ? ( - - - - ) : ( - - )} - {count} / {weeks.length} - {count > 0 ? ( - - - - ) : ( - - )} -
-
-
- ); -}; - -// Rooms / layout editor ───────────────────────────────────────────────────────────── -const RoomsScreen = () => { - return ( -
-
-
-
Räume
-
- Layout-Editor · {ROOM.name} -
-
Räume sind kursunabhängig — einmal anlegen, mehrere Semester nutzen.
-
-
- - -
-
- -
- {/* Tool palette */} -
-
-
Werkzeuge
-
- - - - - - -
-
- -
- -
-
Eigenschaften
-
- - -
- - -
-
-
- -
- -
-
Räume
-
- - - - -
-
-
- - {/* Editor canvas with seat being dragged */} -
-
-
Bearbeiten
-
760 × 460 · 1× Zoom
-
-
- -
-
- ziehen um Sitz zu verschieben → -
-
-
-
- ); -}; - -const ToolBtn = ({ label, active }) => ( - -); - -const Field = ({ label, value, mono }) => ( - -); - -const RoomItem = ({ label, sub, active }) => ( - -); - -// Students CRUD ───────────────────────────────────────────────────────────── -const StudentsScreen = () => { - return ( -
-
-
-
Studierende
-
- {STUDENTS.length} Studierende · {COURSE.name} -
-
-
-
- - -
- - -
-
- -
- - - - - - - - - - - - - - {STUDENTS.map((s, i) => { - const count = (s.id * 7) % 5; - const noteCount = (s.id * 3) % 4; - return ( - - - - - - - - - - ); - })} - -
#NameAnwesendBonusNotizenLetzte Sitzung
{String(i + 1).padStart(2, "0")} -
- {s.initials} - {s.name} -
-
{count} / 4 - {count > 0 ? ( - - - - ) : ( - - )} - - {noteCount > 0 ? ( - - - {noteCount} Notizen - - ) : ( - - )} - Do, 23. April · T{(s.id % 4) + 1}-{(s.id % 5) + 1}
-
-
- ); -}; - -// Login ───────────────────────────────────────────────────────────── -const LoginScreen = () => { - return ( -
-
-
- Tutor·manager -
-
- Anwesenheit & Notizen für Tutorien. -
- -
-
Anmeldung
-
Willkommen zurück
- - - - - - - -
- Nur für Tutor:innen. Studierende nutzen den vom Beamer projizierten Code. -
-
- -
- ~ Donnerstags ab 14 Uhr ~ -
-
-
- ); -}; - -Object.assign(window, { TutorShell, Dashboard, AttendanceMatrix, RoomsScreen, StudentsScreen, LoginScreen }); diff --git a/docs/design_handoff/browser-window.jsx b/docs/design_handoff/browser-window.jsx deleted file mode 100644 index b90e273..0000000 --- a/docs/design_handoff/browser-window.jsx +++ /dev/null @@ -1,114 +0,0 @@ - -// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS) -// No dependencies, no image assets. All inline styles + inline SVG. - -const CHROME_C = { - barBg: '#202124', - tabBg: '#35363a', - text: '#e8eaed', - dim: '#9aa0a6', - urlBg: '#282a2d', -}; - -function ChromeTrafficLights() { - return ( -
-
-
-
-
- ); -} - -// Single tab (active has curved scoops) -function ChromeTab({ title = 'New Tab', active = false }) { - const curve = (flip) => ( - - - - ); - return ( -
- {active && curve(false)} - {active && curve(true)} -
- {title} -
- ); -} - -function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) { - return ( -
- -
- {tabs.map((t, i) => )} -
-
- ); -} - -function ChromeToolbar({ url = 'example.com' }) { - const iconDot = ( -
-
-
- ); - return ( -
- {iconDot} - {/* url bar */} -
-
- {url} -
- {iconDot} -
- ); -} - -function ChromeWindow({ - tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com', - width = 900, height = 600, children, -}) { - return ( -
- - -
- {children} -
-
- ); -} - -Object.assign(window, { - ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights, -}); diff --git a/docs/design_handoff/design-canvas.jsx b/docs/design_handoff/design-canvas.jsx deleted file mode 100644 index 9f3fc61..0000000 --- a/docs/design_handoff/design-canvas.jsx +++ /dev/null @@ -1,622 +0,0 @@ - -// DesignCanvas.jsx — Figma-ish design canvas wrapper -// Warm gray grid bg + Sections + Artboards + PostIt notes. -// Artboards are reorderable (grip-drag), labels/titles are inline-editable, -// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc). -// State persists to a .design-canvas.state.json sidecar via the host -// bridge. No assets, no deps. -// -// Usage: -// -// -// -// -// -// - -const DC = { - bg: '#f0eee9', - grid: 'rgba(0,0,0,0.06)', - label: 'rgba(60,50,40,0.7)', - title: 'rgba(40,30,20,0.85)', - subtitle: 'rgba(60,50,40,0.6)', - postitBg: '#fef4a8', - postitText: '#5a4a2a', - font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', -}; - -// One-time CSS injection (classes are dc-prefixed so they don't collide with -// the hosted design's own styles). -if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { - const s = document.createElement('style'); - s.id = 'dc-styles'; - s.textContent = [ - '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', - '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', - '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', - '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', - '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', - '.dc-card{transition:box-shadow .15s,transform .15s}', - '.dc-card *{scrollbar-width:none}', - '.dc-card *::-webkit-scrollbar{display:none}', - '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}', - '.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}', - '.dc-grip:hover{background:rgba(0,0,0,.08)}', - '.dc-grip:active{cursor:grabbing}', - '.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}', - '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', - '.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;', - ' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', - ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}', - '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}', - '[data-dc-slot]:hover .dc-expand{opacity:1}', - ].join('\n'); - document.head.appendChild(s); -} - -const DCCtx = React.createContext(null); - -// ───────────────────────────────────────────────────────────── -// DesignCanvas — stateful wrapper around the pan/zoom viewport. -// Owns runtime state (per-section order, renamed titles/labels, focused -// artboard). Order/titles/labels persist to a .design-canvas.state.json -// sidecar next to the HTML. Reads go via plain fetch() so the saved -// arrangement is visible anywhere the HTML + sidecar are served together -// (omelette preview, direct link, downloaded zip). Writes go through the -// host's window.omelette bridge — editing requires the omelette runtime. -// Focus is ephemeral. -// ───────────────────────────────────────────────────────────── -const DC_STATE_FILE = '.design-canvas.state.json'; - -function DesignCanvas({ children, minScale, maxScale, style }) { - const [state, setState] = React.useState({ sections: {}, focus: null }); - // Hold rendering until the sidecar read settles so the saved order/titles - // appear on first paint (no source-order flash). didRead gates writes until - // the read settles so the empty initial state can't clobber a slow read; - // skipNextWrite suppresses the one echo-write that would otherwise follow - // hydration. - const [ready, setReady] = React.useState(false); - const didRead = React.useRef(false); - const skipNextWrite = React.useRef(false); - - React.useEffect(() => { - let off = false; - fetch('./' + DC_STATE_FILE) - .then((r) => (r.ok ? r.json() : null)) - .then((saved) => { - if (off || !saved || !saved.sections) return; - skipNextWrite.current = true; - setState((s) => ({ ...s, sections: saved.sections })); - }) - .catch(() => {}) - .finally(() => { didRead.current = true; if (!off) setReady(true); }); - const t = setTimeout(() => { if (!off) setReady(true); }, 150); - return () => { off = true; clearTimeout(t); }; - }, []); - - React.useEffect(() => { - if (!didRead.current) return; - if (skipNextWrite.current) { skipNextWrite.current = false; return; } - const t = setTimeout(() => { - window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); - }, 250); - return () => clearTimeout(t); - }, [state.sections]); - - // Build registries synchronously from children so FocusOverlay can read - // them in the same render. Only direct DCSection > DCArtboard children are - // walked — wrapping them in other elements opts out of focus/reorder. - const registry = {}; // slotId -> { sectionId, artboard } - const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } - const sectionOrder = []; - React.Children.forEach(children, (sec) => { - if (!sec || sec.type !== DCSection) return; - const sid = sec.props.id ?? sec.props.title; - if (!sid) return; - sectionOrder.push(sid); - const persisted = state.sections[sid] || {}; - const srcIds = []; - React.Children.forEach(sec.props.children, (ab) => { - if (!ab || ab.type !== DCArtboard) return; - const aid = ab.props.id ?? ab.props.label; - if (!aid) return; - registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; - srcIds.push(aid); - }); - const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); - sectionMeta[sid] = { - title: persisted.title ?? sec.props.title, - subtitle: sec.props.subtitle, - slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], - }; - }); - - const api = React.useMemo(() => ({ - state, - section: (id) => state.sections[id] || {}, - patchSection: (id, p) => setState((s) => ({ - ...s, - sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, - })), - setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), - }), [state]); - - // Esc exits focus; any outside pointerdown commits an in-progress rename. - React.useEffect(() => { - const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; - const onPd = (e) => { - const ae = document.activeElement; - if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); - }; - document.addEventListener('keydown', onKey); - document.addEventListener('pointerdown', onPd, true); - return () => { - document.removeEventListener('keydown', onKey); - document.removeEventListener('pointerdown', onPd, true); - }; - }, [api]); - - return ( - - {ready && children} - {state.focus && registry[state.focus] && ( - - )} - - ); -} - -// ───────────────────────────────────────────────────────────── -// DCViewport — transform-based pan/zoom (internal) -// -// Input mapping (Figma-style): -// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) -// • trackpad scroll → pan (two-finger) -// • mouse wheel → zoom (notched; distinguished from trackpad scroll) -// • middle-drag / primary-drag-on-bg → pan -// -// Transform state lives in a ref and is written straight to the DOM -// (translate3d + will-change) so wheel ticks don't go through React — -// keeps pans at 60fps on dense canvases. -// ───────────────────────────────────────────────────────────── -function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { - const vpRef = React.useRef(null); - const worldRef = React.useRef(null); - const tf = React.useRef({ x: 0, y: 0, scale: 1 }); - - const apply = React.useCallback(() => { - const { x, y, scale } = tf.current; - const el = worldRef.current; - if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; - }, []); - - React.useEffect(() => { - const vp = vpRef.current; - if (!vp) return; - - const zoomAt = (cx, cy, factor) => { - const r = vp.getBoundingClientRect(); - const px = cx - r.left, py = cy - r.top; - const t = tf.current; - const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); - const k = next / t.scale; - // keep the world point under the cursor fixed - t.x = px - (px - t.x) * k; - t.y = py - (py - t.y) * k; - t.scale = next; - apply(); - }; - - // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends - // line-mode deltas (Firefox) or large integer pixel deltas with no X - // component (Chrome/Safari, typically multiples of 100/120). Trackpad - // two-finger scroll sends small/fractional pixel deltas, often with - // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. - const isMouseWheel = (e) => - e.deltaMode !== 0 || - (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); - - const onWheel = (e) => { - e.preventDefault(); - if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels - if (e.ctrlKey) { - // trackpad pinch (or explicit ctrl+wheel) - zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); - } else if (isMouseWheel(e)) { - // notched mouse wheel — fixed-ratio step per click - zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); - } else { - // trackpad two-finger scroll — pan - tf.current.x -= e.deltaX; - tf.current.y -= e.deltaY; - apply(); - } - }; - - // Safari sends native gesture* events for trackpad pinch with a smooth - // e.scale; preferring these over the ctrl+wheel fallback gives a much - // better feel there. No-ops on other browsers. Safari also fires - // ctrlKey wheel events during the same pinch — isGesturing makes - // onWheel drop those entirely so they neither zoom nor pan. - let gsBase = 1; - let isGesturing = false; - const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; - const onGestureChange = (e) => { - e.preventDefault(); - zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); - }; - const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; - - // Drag-pan: middle button anywhere, or primary button on canvas - // background (anything that isn't an artboard or an inline editor). - let drag = null; - const onPointerDown = (e) => { - const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); - if (!(e.button === 1 || (e.button === 0 && onBg))) return; - e.preventDefault(); - vp.setPointerCapture(e.pointerId); - drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; - vp.style.cursor = 'grabbing'; - }; - const onPointerMove = (e) => { - if (!drag || e.pointerId !== drag.id) return; - tf.current.x += e.clientX - drag.lx; - tf.current.y += e.clientY - drag.ly; - drag.lx = e.clientX; drag.ly = e.clientY; - apply(); - }; - const onPointerUp = (e) => { - if (!drag || e.pointerId !== drag.id) return; - vp.releasePointerCapture(e.pointerId); - drag = null; - vp.style.cursor = ''; - }; - - vp.addEventListener('wheel', onWheel, { passive: false }); - vp.addEventListener('gesturestart', onGestureStart, { passive: false }); - vp.addEventListener('gesturechange', onGestureChange, { passive: false }); - vp.addEventListener('gestureend', onGestureEnd, { passive: false }); - vp.addEventListener('pointerdown', onPointerDown); - vp.addEventListener('pointermove', onPointerMove); - vp.addEventListener('pointerup', onPointerUp); - vp.addEventListener('pointercancel', onPointerUp); - return () => { - vp.removeEventListener('wheel', onWheel); - vp.removeEventListener('gesturestart', onGestureStart); - vp.removeEventListener('gesturechange', onGestureChange); - vp.removeEventListener('gestureend', onGestureEnd); - vp.removeEventListener('pointerdown', onPointerDown); - vp.removeEventListener('pointermove', onPointerMove); - vp.removeEventListener('pointerup', onPointerUp); - vp.removeEventListener('pointercancel', onPointerUp); - }; - }, [apply, minScale, maxScale]); - - const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; - return ( -
-
-
- {children} -
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// DCSection — editable title + h-row of artboards in persisted order -// ───────────────────────────────────────────────────────────── -function DCSection({ id, title, subtitle, children, gap = 48 }) { - const ctx = React.useContext(DCCtx); - const sid = id ?? title; - const all = React.Children.toArray(children); - const artboards = all.filter((c) => c && c.type === DCArtboard); - const rest = all.filter((c) => !(c && c.type === DCArtboard)); - const srcOrder = artboards.map((a) => a.props.id ?? a.props.label); - const sec = (ctx && sid && ctx.section(sid)) || {}; - - const order = React.useMemo(() => { - const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); - return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; - }, [sec.order, srcOrder.join('|')]); - - const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); - - return ( -
-
- ctx && sid && ctx.patchSection(sid, { title: v })} - style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> - {subtitle &&
{subtitle}
} -
-
- {order.map((k) => ( - ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} - onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} - onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> - ))} -
- {rest} -
- ); -} - -// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. -function DCArtboard() { return null; } - -function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) { - const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; - const id = rawId ?? rawLabel; - const ref = React.useRef(null); - - // Live drag-reorder: dragged card sticks to cursor; siblings slide into - // their would-be slots in real time via transforms. DOM order only - // changes on drop. - const onGripDown = (e) => { - e.preventDefault(); e.stopPropagation(); - const me = ref.current; - // translateX is applied in local (pre-scale) space but pointer deltas and - // getBoundingClientRect().left are screen-space — divide by the viewport's - // current scale so the dragged card tracks the cursor at any zoom level. - const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; - const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); - const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); - const slotXs = homes.map((h) => h.x); - const startIdx = order.indexOf(id); - const startX = e.clientX; - let liveOrder = order.slice(); - me.classList.add('dc-dragging'); - - const layout = () => { - for (const h of homes) { - if (h.id === id) continue; - const slot = liveOrder.indexOf(h.id); - h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; - } - }; - - const move = (ev) => { - const dx = ev.clientX - startX; - me.style.transform = `translateX(${dx / scale}px)`; - const cur = homes[startIdx].x + dx; - let nearest = 0, best = Infinity; - for (let i = 0; i < slotXs.length; i++) { - const d = Math.abs(slotXs[i] - cur); - if (d < best) { best = d; nearest = i; } - } - if (liveOrder.indexOf(id) !== nearest) { - liveOrder = order.filter((k) => k !== id); - liveOrder.splice(nearest, 0, id); - layout(); - } - }; - - const up = () => { - document.removeEventListener('pointermove', move); - document.removeEventListener('pointerup', up); - const finalSlot = liveOrder.indexOf(id); - me.classList.remove('dc-dragging'); - me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; - // After the settle transition, kill transitions + clear transforms + - // commit the reorder in the same frame so there's no visual snap-back. - setTimeout(() => { - for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } - if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); - requestAnimationFrame(() => requestAnimationFrame(() => { - for (const h of homes) h.el.style.transition = ''; - })); - }, 180); - }; - document.addEventListener('pointermove', move); - document.addEventListener('pointerup', up); - }; - - return ( -
-
-
- -
-
- e.stopPropagation()} - style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> -
-
- -
- {children ||
{id}
} -
-
- ); -} - -// Inline rename — commits on blur or Enter. -function DCEditable({ value, onChange, style, tag = 'span', onClick }) { - const T = tag; - return ( - e.stopPropagation()} - onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} - style={style}>{value} - ); -} - -// ───────────────────────────────────────────────────────────── -// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across -// sections, Esc or backdrop click to exit. -// ───────────────────────────────────────────────────────────── -function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { - const ctx = React.useContext(DCCtx); - const { sectionId, artboard } = entry; - const sec = ctx.section(sectionId); - const meta = sectionMeta[sectionId]; - const peers = meta.slotIds; - const aid = artboard.props.id ?? artboard.props.label; - const idx = peers.indexOf(aid); - const secIdx = sectionOrder.indexOf(sectionId); - - const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; - const goSection = (d) => { - const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length]; - const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; - if (first) ctx.setFocus(`${ns}/${first}`); - }; - - React.useEffect(() => { - const k = (e) => { - if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } - if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } - if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } - if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } - }; - document.addEventListener('keydown', k); - return () => document.removeEventListener('keydown', k); - }); - - const { width = 260, height = 480, children } = artboard.props; - const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); - React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); - const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); - - const [ddOpen, setDd] = React.useState(false); - const Arrow = ({ dir, onClick }) => ( - - ); - - // Portal to body so position:fixed is the real viewport regardless of any - // transform on DesignCanvas's ancestors (including the canvas zoom itself). - return ReactDOM.createPortal( -
ctx.setFocus(null)} - onWheel={(e) => e.preventDefault()} - style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', - fontFamily: DC.font, color: '#fff' }}> - - {/* top bar: section dropdown (left) · close (right) */} -
e.stopPropagation()} - style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> -
- - {ddOpen && ( -
- {sectionOrder.map((sid) => ( - - ))} -
- )} -
-
- -
- - {/* card centered, label + index below — only the card itself stops - propagation so any backdrop click (including the margins around - the card) exits focus */} -
-
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> -
- {children ||
{aid}
} -
-
-
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> - {(sec.labels || {})[aid] ?? artboard.props.label} - {idx + 1} / {peers.length} -
-
- - go(-1)} /> - go(1)} /> - - {/* dots */} -
e.stopPropagation()} - style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> - {peers.map((p, i) => ( -
-
, - document.body, - ); -} - -// ───────────────────────────────────────────────────────────── -// Post-it — absolute-positioned sticky note -// ───────────────────────────────────────────────────────────── -function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { - return ( -
{children}
- ); -} - -Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); - diff --git a/docs/design_handoff/ios-frame.jsx b/docs/design_handoff/ios-frame.jsx deleted file mode 100644 index 1a15e27..0000000 --- a/docs/design_handoff/ios-frame.jsx +++ /dev/null @@ -1,338 +0,0 @@ - -// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame -// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps. -// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard - -// ───────────────────────────────────────────────────────────── -// Status bar -// ───────────────────────────────────────────────────────────── -function IOSStatusBar({ dark = false, time = '9:41' }) { - const c = dark ? '#fff' : '#000'; - return ( -
-
- {time} -
-
- - - - - - - - - - - - - - - - -
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Liquid glass pill — blur + tint + shine -// ───────────────────────────────────────────────────────────── -function IOSGlassPill({ children, dark = false, style = {} }) { - return ( -
- {/* blur + tint */} -
- {/* shine */} -
-
- {children} -
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Navigation bar — glass pills + large title -// ───────────────────────────────────────────────────────────── -function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) { - const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040'; - const text = dark ? '#fff' : '#000'; - const pillIcon = (content) => ( - -
- {content} -
-
- ); - return ( -
-
- {/* back chevron */} - {pillIcon( - - - - )} - {/* trailing ellipsis */} - {trailingIcon && pillIcon( - - - - - - )} -
- {/* large title */} -
{title}
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Grouped list (inset card, r:26) + row (52px) -// ───────────────────────────────────────────────────────────── -function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) { - const text = dark ? '#fff' : '#000'; - const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; - const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)'; - const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)'; - return ( -
- {icon && ( -
- )} -
{title}
- {detail && {detail}} - {chevron && ( - - - - )} - {!isLast && ( -
- )} -
- ); -} - -function IOSList({ header, children, dark = false }) { - const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)'; - const bg = dark ? '#1C1C1E' : '#fff'; - return ( -
- {header && ( -
{header}
- )} -
{children}
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Device frame -// ───────────────────────────────────────────────────────────── -function IOSDevice({ - children, width = 402, height = 874, dark = false, - title, keyboard = false, -}) { - return ( -
- {/* dynamic island */} -
- {/* status bar (absolute) */} -
- -
- {/* nav + content */} -
- {title !== undefined && } -
{children}
- {keyboard && } -
- {/* home indicator — always on top */} -
-
-
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Keyboard — iOS 26 liquid glass -// ───────────────────────────────────────────────────────────── -function IOSKeyboard({ dark = false }) { - const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959'; - const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333'; - const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)'; - - // special-key icons - const icons = { - shift: , - del: , - ret: , - }; - - const key = (content, { w, flex, ret, fs = 25, k } = {}) => ( -
{content}
- ); - - const row = (keys, pad = 0) => ( -
- {keys.map(l => key(l, { flex: true, k: l }))} -
- ); - - return ( -
- {/* liquid glass bg — same recipe as nav pills */} -
-
- - {/* autocorrect bar */} -
- {['"The"', 'the', 'to'].map((w, i) => ( - - {i > 0 &&
} -
{w}
- - ))} -
- - {/* key layout */} -
- {row(['q','w','e','r','t','y','u','i','o','p'])} - {row(['a','s','d','f','g','h','j','k','l'], 20)} -
- {key(icons.shift, { w: 45, k: 'shift' })} -
- {['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))} -
- {key(icons.del, { w: 45, k: 'del' })} -
-
- {key('ABC', { w: 92.25, fs: 18, k: 'abc' })} - {key('', { flex: true, k: 'space' })} - {key(icons.ret, { w: 92.25, ret: true, k: 'ret' })} -
-
- - {/* bottom spacer (emoji+mic area, icons omitted) */} -
-
- ); -} - -Object.assign(window, { - IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard, -}); diff --git a/docs/design_handoff/rooms.jsx b/docs/design_handoff/rooms.jsx deleted file mode 100644 index f64f172..0000000 --- a/docs/design_handoff/rooms.jsx +++ /dev/null @@ -1,244 +0,0 @@ -// rooms.jsx — richer Layout-Editor for rooms - -const ROOM_LIST = [ - { id: "bc2-1103", name: "BC2 1.103", sub: "20 Sitze · 4 Tische", building: "Campus Belval", capacity: 20, used: "Funktionale Programmierung · SS 2026" }, - { id: "bc2-1207", name: "BC2 1.207", sub: "16 Sitze · 4 Tische", building: "Campus Belval", capacity: 16, used: "Datenbanken · SS 2026" }, - { id: "lib-004", name: "Lib 0.04", sub: "12 Sitze · 3 Tische", building: "Bibliothek", capacity: 12, used: "—" }, - { id: "kirch-12", name: "Kirchberg E12", sub: "24 Sitze · 6 Tische", building: "Kirchberg", capacity: 24, used: "Algorithmen · WS 2025" }, -]; - -const RoomsScreenV2 = () => { - const [activeRoom, setActiveRoom] = React.useState("bc2-1103"); - const [activeTool, setActiveTool] = React.useState("seat"); - const [selectedEl, setSelectedEl] = React.useState({ kind: "seat", id: "T2-3", x: 540, y: 194, table: "T2" }); - - const tools = [ - { id: "select", label: "Auswählen", icon: "↖" }, - { id: "seat", label: "Sitz", icon: "○" }, - { id: "table", label: "Tisch", icon: "▭" }, - { id: "door", label: "Tür", icon: "⌐" }, - { id: "window", label: "Fenster", icon: "‖" }, - { id: "gap", label: "Lücke", icon: "·" }, - ]; - - const room = ROOM_LIST.find((r) => r.id === activeRoom); - - return ( -
-
-
-
Räume · Layout-Editor
-
- {room.name} · {room.building} -
-
Räume sind kursunabhängig — einmal anlegen, mehrere Semester nutzen. Aktuell: {room.used}
-
-
- - - -
-
- -
- {/* LEFT: Rooms list */} -
-
-
Räume
- -
-
- {ROOM_LIST.map((r) => ( - - ))} -
-
- - {/* CENTER: Toolbar + canvas */} -
- {/* Toolbar */} -
-
- {tools.map((t) => ( - - ))} -
-
-
- - 78% - - · - Raster: 24px -
-
- - {/* Canvas */} -
- {/* Rulers */} -
- {[0, 100, 200, 300, 400, 500, 600, 700].map((n) => ( -
{n}
- ))} -
-
- {[0, 100, 200, 300, 400].map((n) => ( -
{n}
- ))} -
- -
-
- - - {/* Selection ring on T2-3 */} -
- {/* drag handles */} - {[[0,-1],[1,0],[0,1],[-1,0]].map(([dx,dy], i) => ( -
- ))} - - {/* Marginalia */} -
- Sitz aus Palette ziehen
oder Doppelklick → -
-
- Wand mit Snap-Raster -
-
-
-
- - {/* Bottom status bar */} -
- 20 Elemente - · - 20 Sitze · 4 Tische · 1 Tür · 1 Fenster · 1 Beamer -
- Auto-gespeichert · 11:42 -
-
- - {/* RIGHT: Properties + layers */} -
-
-
Auswahl
-
Sitz {selectedEl.id}
-
gehört zu Tisch {selectedEl.table}
- -
- - -
- - -
-
- - -
-
- -
- -
Aktionen
-
- - - -
-
- -
-
-
Ebenen
- 20 -
-
- {[ - { kind: "wand", label: "Wände" }, - { kind: "tisch", label: "Tisch T1 + 5 Sitze", count: 6 }, - { kind: "tisch", label: "Tisch T2 + 5 Sitze", count: 6, sel: true }, - { kind: "tisch", label: "Tisch T3 + 5 Sitze", count: 6 }, - { kind: "tisch", label: "Tisch T4 + 5 Sitze", count: 6 }, - { kind: "elem", label: "Pult / Tutor:in" }, - { kind: "elem", label: "Beamer" }, - { kind: "elem", label: "Tür" }, - { kind: "elem", label: "Fenster Nord" }, - ].map((l, i) => ( -
- {l.kind === "wand" ? "▢" : l.kind === "tisch" ? "▤" : "·"} - {l.label} - {l.count && {l.count}} -
- ))} -
-
-
-
-
- ); -}; - -const FieldV2 = ({ label, value, mono, suffix }) => ( - -); - -// keyframe for selection spin -if (typeof document !== "undefined" && !document.getElementById("rooms-anim")) { - const s = document.createElement("style"); - s.id = "rooms-anim"; - s.textContent = "@keyframes spin { to { transform: rotate(360deg); } }"; - document.head.appendChild(s); -} - -Object.assign(window, { RoomsScreenV2 }); diff --git a/docs/design_handoff/seatmap.jsx b/docs/design_handoff/seatmap.jsx deleted file mode 100644 index 5e110a2..0000000 --- a/docs/design_handoff/seatmap.jsx +++ /dev/null @@ -1,446 +0,0 @@ -// seatmap.jsx — top-down room layout with tables, chairs, and tutor notes panel - -// SeatMap — pure visual; takes assignments + selectedStudent + variant + onSeatClick -function SeatMap({ - assignments = SEAT_ASSIGN, - selectedStudent, - onSeatClick, - hoveredSeat, - setHoveredSeat, - variant = "tutor", // "tutor" | "student" | "student-self" - ownSeat, // for student view: which seat is "mine" - scale = 1, -}) { - const W = ROOM.width, H = ROOM.height; - const studentBy = (id) => STUDENTS.find((s) => s.id === id); - - return ( -
- {/* Inner ruled grid — like graph paper */} -
- -
- {/* Walls */} -
- - {/* Window */} -
-
-
-
Fenster
- - {/* Door — gap with arc */} -
- - - - -
Tür
- - {/* Beamer */} -
-
Beamer
- - {/* Podium */} -
- Pult · Tutor:in -
- - {/* Tables */} - {ROOM.tables.map((t) => ( -
- {t.label} -
- ))} - - {/* Seats */} - {SEATS.map((seat) => { - const sid = assignments[seat.id]; - const student = sid ? studentBy(sid) : null; - const isSelected = selectedStudent && sid === selectedStudent; - const isHover = hoveredSeat === seat.id; - const isOwn = ownSeat === seat.id; - - let bg, border, label, labelColor; - if (variant === "tutor") { - if (student) { - bg = isSelected ? "var(--ink)" : (isHover ? "#e0d4b6" : "#fbf7ee"); - border = isSelected ? "var(--ink)" : "var(--ink-2)"; - label = student.initials; - labelColor = isSelected ? "#f7eedc" : "var(--ink)"; - } else { - bg = "#f7f1e3"; - border = "var(--ink-4)"; - label = ""; - labelColor = "var(--ink-4)"; - } - } else if (variant === "student") { - // student picking a seat - if (student) { - bg = "#d6cdb5"; // grey occupied - border = "var(--ink-4)"; - label = ""; - } else if (isOwn) { - bg = "var(--accent)"; - border = "var(--accent)"; - label = ""; - } else { - bg = "#fbf7ee"; - border = "var(--ink-2)"; - label = ""; - } - } else if (variant === "student-self") { - // read-only own seat - if (isOwn) { - bg = "var(--accent)"; - border = "var(--accent)"; - } else if (student) { - bg = "#d6cdb5"; - border = "var(--ink-4)"; - } else { - bg = "#fbf7ee"; - border = "var(--ink-3)"; - } - label = ""; - } - - return ( - - ); - })} - - {/* Compass */} -
-
- N - -
-
-
-
- ); -} - -// ───────────────────────────────────────────────────────────── -// Tutor live view — seat map + roster + notes panel -// ───────────────────────────────────────────────────────────── - -function TutorLiveView({ variant = "split" }) { - // variant: "split" (map + side panel) | "stacked" (notes below) | "compact" - const [selected, setSelected] = React.useState(3); - const [hovered, setHovered] = React.useState(null); - const [notes, setNotes] = React.useState(NOTES); - - const presentIds = Object.values(SEAT_ASSIGN); - const presentSet = new Set(presentIds); - const present = STUDENTS.filter((s) => presentSet.has(s.id)); - const absent = STUDENTS.filter((s) => !presentSet.has(s.id)); - const sel = STUDENTS.find((s) => s.id === selected); - - const seatOf = (sid) => Object.entries(SEAT_ASSIGN).find(([, v]) => v === sid)?.[0]; - - return ( -
- {/* Header bar */} -
-
-
Tutor:innen-Ansicht · Live
-
- Woche 04 · Donnerstag, 30. April 2026 -
-
- {COURSE.name}, {COURSE.semester} - · - {ROOM.name} - · - 14:00 – 15:00 Uhr -
-
- -
-
-
Check-in Code
-
K7QJ-MX2P
-
tutor.puchstein.dev/s/K7QJMX2P
-
- - - -
-
- -
- - {/* Body grid */} -
- {/* Seat map column */} -
-
-
-
Sitzplan
- -
-
- - - -
-
- -
- { - const sid = SEAT_ASSIGN[seat.id]; - if (sid) setSelected(sid); - }} - variant="tutor" - scale={0.85} - /> -
- - {/* Tally */} -
- - - -
- -
-
- - {/* Notes panel column */} -
- {/* Roster */} -
-
Studierende
-
{present.length} anwesend · {absent.length} fehlen
-
- -
- {present.map((s) => { - const isSel = s.id === selected; - const hasNote = notes[s.id]; - return ( - - ); - })} - {absent.map((s) => ( - - ))} -
- - {/* Note editor */} -
-
- {sel?.initials} -
-
{sel?.name}
-
Sitzplatz {seatOf(selected) || "—"} · seit {CHECKED_IN_AT[selected] || "—"}
-
- Präsent -
- -
Notiz · Woche 04
-