diff --git a/docs/design_handoff/README.md b/docs/design_handoff/README.md new file mode 100644 index 0000000..2748f5c --- /dev/null +++ b/docs/design_handoff/README.md @@ -0,0 +1,325 @@ +# 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 new file mode 100644 index 0000000..93420cb --- /dev/null +++ b/docs/design_handoff/Tutormanager.html @@ -0,0 +1,192 @@ + + + + +Tutormanager — Anwesenheit & Notizen + + + + + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/docs/design_handoff/admin.jsx b/docs/design_handoff/admin.jsx new file mode 100644 index 0000000..25e85f0 --- /dev/null +++ b/docs/design_handoff/admin.jsx @@ -0,0 +1,489 @@ +// 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 new file mode 100644 index 0000000..b90e273 --- /dev/null +++ b/docs/design_handoff/browser-window.jsx @@ -0,0 +1,114 @@ + +// 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 new file mode 100644 index 0000000..9f3fc61 --- /dev/null +++ b/docs/design_handoff/design-canvas.jsx @@ -0,0 +1,622 @@ + +// 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 new file mode 100644 index 0000000..1a15e27 --- /dev/null +++ b/docs/design_handoff/ios-frame.jsx @@ -0,0 +1,338 @@ + +// 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 new file mode 100644 index 0000000..f64f172 --- /dev/null +++ b/docs/design_handoff/rooms.jsx @@ -0,0 +1,244 @@ +// 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 new file mode 100644 index 0000000..5e110a2 --- /dev/null +++ b/docs/design_handoff/seatmap.jsx @@ -0,0 +1,446 @@ +// 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
+