diff --git a/AGENTS.md b/AGENTS.md index b6f95f7f..b4d17282 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,6 +499,33 @@ Use `@cio/ui/base/page` for dashboard page shells. See `packages/ui/README.md` /> ``` +### Scroll to top + +Any surface whose **main column can overflow the viewport** must use the shared `ScrollToTop` control. Do not invent a one-off back-to-top button, FAB, or “scroll up” link. + +Full behavior, threshold, clearance, and mount points: `prd/scroll-to-top/README.md`. + +**Rules:** + +- Implement and import from `@cio/ui/custom/scroll-to-top` (see that spec if the file is not there yet — add the shared component, do not inline a button in the page). +- Hidden until the scroll container overflows **and** the user has scrolled at least **one viewport**. Hidden again below 0.8 viewports. Never visible at the top of the page. +- Click only scrolls that container to `top: 0`. Prefer `behavior: 'smooth'`; use instant when `prefers-reduced-motion` is set. +- Fixed to the **viewport**, bottom-right, icon-only `Button`/`IconButton` with `variant="secondary"`. +- Pass `label` from the host (`$t('common.scroll_to_top')` in the dashboard). The UI package does not own copy. +- Prefer **one mount per shell**, not per leaf page: + - Public lessons and exercises → `PublicCourse.PublicCourseShell` (`clearance="mobile-nav"`). + - Authenticated course contents list, lesson, and exercise → `routes/(app)/courses/[id]/+layout.svelte` (Ask AI clearance on lesson/exercise routes). +- New long-read pages (community thread, public course landing, long catalogs, etc.) must include the same component. Do not mount it on `(app)/+layout.svelte` or inside dialogs/sheets/nested panes. + +```svelte + + + +``` + ## Emails: system vs org-branded Every transactional email in `packages/email/src/emails` is one of two kinds — decide deliberately, because it changes the branding, the schema, and the `from` address. @@ -540,6 +567,7 @@ Links embedded in transactional emails must use the correct dashboard host. Temp - Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`) - Reset modal/form state in close/submit handlers or `onOpenChange`, not in `$effect` tied to a steady “closed” condition - Mutate bound `$state` object fields in place when clearing forms (don't reassign the whole object) +- Use `ScrollToTop` (`@cio/ui/custom/scroll-to-top`) on any page whose main column can overflow the viewport (see **Scroll to top**) ### ❌ DON'T - Put business logic in routes or queries @@ -557,6 +585,7 @@ Links embedded in transactional emails must use the correct dashboard host. Temp - **Use `$effect` to reset form/modal state whenever a boolean is false** — use `onOpenChange` or explicit handlers on close instead - **Reassign whole bound state objects to clear forms** (e.g. `fields = {}`) — mutate properties in place - **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead +- Build a one-off back-to-top button — use `ScrollToTop` (see **Scroll to top** and `prd/scroll-to-top/README.md`) ## Checklist for New Routes diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index 4ae8550d..ed3ee0d3 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -27,6 +27,7 @@ Use this file to keep a single prioritized view of all PRDs. | - | - | `prd/release-process` | draft | | - | - | `prd/events` | draft | | - | - | `prd/youtube-embed-transcripts` | draft | +| - | - | `prd/scroll-to-top` | draft | ## DONE (Completed PRDs) diff --git a/prd/scroll-to-top/README.md b/prd/scroll-to-top/README.md new file mode 100644 index 00000000..ddf088ff --- /dev/null +++ b/prd/scroll-to-top/README.md @@ -0,0 +1,235 @@ +# Scroll to Top + +## Status + +- Draft (spec only; not implemented) + +## Purpose + +Give learners (and anyone else reading a long page) a single, shared way to jump back to the top after they have scrolled away from it. The control is a floating button in the bottom-right corner. It is **hidden until the page actually overflows and the user has scrolled a meaningful distance**. Clicking it scrolls to the top. That is the entire interaction. + +This is a **shared UI convention**, not a per-page invention. Any surface whose main column can grow taller than the viewport must use this component. Do not build a one-off back-to-top button. + +## Motivation + +Course-taking pages are the longest surfaces in the product: + +- Public course **lessons** and **exercises** (`/course/[slug]/lesson/[itemSlug]`) — rich-text bodies, video, questions, then a footer prev/next block. +- Authenticated LMS **content list** (`/courses/[id]/lessons`) — many sections and items; teachers and students both scroll the outline. +- Authenticated LMS **lesson** (`/courses/[id]/lessons/[lessonId]`) and **exercise** (`/courses/[id]/exercises/[exerciseId]`) — same long-read / long-form take experience inside the course shell. + +Today there is no way back to the top except manually scrolling. On a phone, after reading a long lesson, that is a lot of work. The same gap will keep appearing on every new long page unless the pattern is a named component plus an `AGENTS.md` rule. + +## Confirmed decisions + +1. **One shared component** in `@cio/ui` (`packages/ui/src/custom/scroll-to-top`). Dashboard, public course, and Storybook all consume it. Copy is passed in as a `label` prop (UI package stays English-free). +2. **Hidden by default.** The button is not in the visual tree at `scrollTop === 0`, even on a long page. +3. **Appears only when both are true:** + - The scroll container overflows (content is taller than the viewport / container). + - The user has scrolled past the **show threshold**. +4. **Show threshold = one viewport height.** `scrollTop >= clientHeight` of the scroll container. See [Threshold recommendation](#threshold-recommendation). +5. **Click does one thing:** smooth-scroll that container to `top: 0`. No hash change, no extra menu, no “scroll to previous section”. +6. **Fixed to the viewport, bottom-right.** Not sticky inside the content column. Hosts that already have bottom chrome (public-course mobile nav, Ask AI bar) pass a clearance so the button sits above that chrome. +7. **Mount in shells for v1**, not on every leaf page. Visibility is still automatic: short pages inside those shells never show the button. +8. **Not a global app layout mount.** Auth screens, short admin lists, and dialogs must not pick it up by accident. + +## Threshold recommendation + +**Show after one viewport of scroll. Hide again below 0.8 viewports.** + +| Option | Show when | Verdict | +| --- | --- | --- | +| 1 viewport (`scrollTop >= clientHeight`) | User has left the first screen | **Choose this.** Device-relative; late enough that a short bump does not flash the button; early enough on a 10-screen lesson. | +| Fixed 400px | Same distance on every device | Reject. ~½ screen on desktop, more than a screen on a short phone. | +| 25–50% of document height | Proportion of *content* | Reject. A long lesson would hide the button until the user is already deep in. | +| Any overflow (show as soon as `scrollTop > 0`) | First pixel of scroll | Reject. Flickers on tiny overscroll / rubber-banding. | + +Hysteresis: **show at 1.0 × viewport, hide at 0.8 × viewport**, so sitting on the boundary does not strobe the button. + +If the page cannot scroll a full viewport (content is only slightly taller than the window), the button never appears. That matches “only when there is real overflow.” + +Constants (implementation must not scatter magic numbers): + +```ts +export const SCROLL_TO_TOP_SHOW_RATIO = 1; +export const SCROLL_TO_TOP_HIDE_RATIO = 0.8; +``` + +Hosts may pass `thresholdPx` only for tests or genuine exceptions. Product default is the ratio above. + +## User stories + +- **As a learner** on a long public lesson, after I have scrolled past the first screen, I want a button in the bottom-right so I can return to the title / video without dragging back. +- **As a learner** taking an LMS exercise with many questions or sections, I want the same control so I can jump back to the exercise heading. +- **As a teacher or student** on the course contents list with many sections, I want the same control after I have scrolled the outline. +- **As a developer** adding a new long-read page, I want one component and an `AGENTS.md` rule so I do not invent a fourth back-to-top button. + +## Feature detail + +### Component + +**Path:** `packages/ui/src/custom/scroll-to-top/` + +**Export:** `ScrollToTop` from `@cio/ui/custom/scroll-to-top` (and re-export from the `@cio/ui` barrel, same as `IconButton`). + +**Visual** + +- Icon-only circular (or `rounded-full`) control. +- Lucide `ArrowUp` (or `ChevronUp`) icon. +- Built on existing `Button` / `IconButton`: `size="icon"` (or `icon-lg` if tap target feels tight on mobile) and **`variant="secondary"`** — icon-only buttons must use secondary. +- Light shadow so it reads as floating above the page (`ui:shadow-md` or equivalent). +- Fade + short translate on enter/leave. If `prefers-reduced-motion: reduce`, skip the motion and scroll with `behavior: 'instant'`. +- Tooltip / `aria-label` from the `label` prop (required). + +**Default placement** + +``` +viewport +┌─────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ ┌───┐ │ ← ~1.5rem from right and bottom +│ │ ↑ │ │ (plus safe-area inset) +│ └───┘ │ +└─────────────────────────────┘ +``` + +- `position: fixed` +- `z-index`: `z-30` — above page content and the public-course sticky header (`z-30` tie: later in DOM wins). Below dialogs/sheets (`z-50+`). Public-course mobile `BottomNav` is `z-40`; the button must **clear it spatially**, not compete in z-index (see [Bottom chrome](#bottom-chrome)). +- Default inset: `right: 1.5rem`; `bottom: max(1.5rem, env(safe-area-inset-bottom) + 1rem)`. + +**Behavior** + +1. Resolve the scroll container: `window` / `document.documentElement` by default, or an element passed as `target`. +2. On scroll (and on resize / content mutation): compute `scrollTop`, `clientHeight`, `scrollHeight`. +3. If `scrollHeight <= clientHeight`, stay hidden. +4. If `scrollTop >= SHOW_RATIO * clientHeight`, show. If `scrollTop < HIDE_RATIO * clientHeight`, hide. +5. On click: `container.scrollTo({ top: 0, behavior })`. Nothing else. +6. On SvelteKit navigation, re-evaluate (new page starts hidden). Do not leak a visible button across routes. + +Listen with `` / `` for the document case (do not wrap this in `onMount` + `addEventListener`). For an element `target`, attach `onscroll` to that node. Throttle visual updates with `requestAnimationFrame` if needed; do not put scroll-position writes inside `$effect`. + +**Props** + +| Prop | Type | Default | Purpose | +| --- | --- | --- | --- | +| `label` | `string` | required | Accessible name and tooltip. Dashboard passes `$t('common.scroll_to_top')`. Public course / Storybook pass a literal or translated string from the host. | +| `target` | `HTMLElement \| null` | `null` (window) | Nested overflow container, if the page does not scroll the document. | +| `clearance` | `'default' \| 'mobile-nav' \| 'ask-ai'` | `'default'` | Bottom offset preset for existing chrome. | +| `class` | `string` | — | Escape hatch for one-off offset. Prefer `clearance`. | + +Do not add an `open` / `visible` prop for product use. Visibility is derived from scroll. Storybook may use a forced-visible story via a dedicated `forceVisible` prop that is **not** used in app code. + +### Bottom chrome + +The button must never sit under another fixed footer. + +| Surface | Existing chrome | `clearance` | +| --- | --- | --- | +| Public course lesson/exercise, `lg:hidden` | `PublicCourse.BottomNav` (`fixed inset-x-0 bottom-0 z-40`; main already has `pb-24`) | `mobile-nav` → roughly `bottom-24` on small screens, default on `lg+` | +| LMS lesson / exercise | `ContentAskAiBar` is **centered** at `bottom-4` / full-width when expanded | `ask-ai` → lift on small screens so the FAB is not on top of the collapsed Ask AI chip; desktop can stay `bottom-6` because Ask AI is centered and the FAB is right-aligned | +| Course contents list, most other pages | None at the bottom-right | `default` | +| Settings pages with `Page.SettingsActions` | Centered sticky card at `z-50` | `default` (right side is free). Do not cover the card. | + +Public-course shell should pass `clearance="mobile-nav"` itself so leaf lesson/exercise views stay unaware of the nav. + +### v1 mount points + +Mount **once per shell**, not on every page file. + +#### 1. Public course shell + +`packages/ui/src/custom/public-course/shell.svelte` + +Covers: + +- Public **lesson** view +- Public **exercise** view + +(`apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte` already renders inside this shell.) + +Window is the scroller (`min-h-dvh`, no overflow trap on `
`). Default `target` is correct. + +#### 2. Authenticated course layout + +`apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte` + +Covers: + +- **Contents / lessons list** — `/courses/[id]/lessons` (flat items or grouped **sections**; this is the “sections / contents” page) +- **Lesson** — `/courses/[id]/lessons/[lessonId]` +- **Exercise** — `/courses/[id]/exercises/[exerciseId]` +- Other course tabs that happen to be long (people, newsfeed, analytics) get the same automatic behavior; that is acceptable + +The dashboard host supplies the translated `label`. The layout already knows when Ask AI is shown (`showContentAskAiBar`); pass `clearance="ask-ai"` on lesson/exercise routes and `default` otherwise. + +Course pages scroll the document today (`Sidebar.Provider` is `max-h-svh` but the inset does not use `overflow-y-auto`). If that ever changes to an inner scroller, pass that element as `target` from the layout — do not fork the component. + +### Dashboard copy + +Add to `apps/dashboard/src/lib/utils/translations/en.json`: + +```json +"common": { + "scroll_to_top": "Back to top" +} +``` + +Run `pnpm translate` after adding the English key. Public course shell lives in `@cio/ui` and has no i18n: the org-site layout/page that constructs `PublicCourseShell` must pass the translated label (same pattern as `exploreLabel` / `signInLabel`). + +### Storybook + +`packages/storybook/src/molecules/scroll-to-top/scroll-to-top.stories.svelte` + +Stories: + +1. **In overflowing container** — a tall `target` box; scrolling past one container-height reveals the button; click returns to top. +2. **No overflow** — short box; button never appears. +3. **Forced visible** — layout/docs only, so authors can see the default, `mobile-nav`, and `ask-ai` offsets without scrolling. + +### Agent / docs updates (this spec’s companion) + +Implementation must keep these in sync: + +- `AGENTS.md` — “Scroll to top” section (already added with this spec; tighten the import path if it moves). +- `packages/ui/README.md` — component section with props, clearance, and “use on any overflowing main column”. +- `packages/ui/src/index.ts` — barrel export. + +## Out of scope (v1) + +- Scroll-spy, section dots, or “back to previous heading”. +- Remembering scroll position across lessons. +- Showing the button inside dialogs, sheets, drawers, or nested panes (transcript, comment trees, selects). Those keep their own scroll. +- A global mount on `(app)/+layout.svelte` or `(org-site)/+layout.svelte`. +- Changing `Page.SettingsActions` or Ask AI layout. +- Analytics on click (add later if we care about usage). + +## Later / same component, more hosts + +Do **not** create a second control. When these pages are long, drop in `ScrollToTop`: + +- Public course landing (`/course/[slug]`) +- Org landing / catalog +- LMS community thread +- Course newsfeed (already under the course layout, so v1 may already cover it) +- Long settings forms (button at bottom-right; save bar stays centered) + +## Implementation checklist + +- [ ] `packages/ui/src/custom/scroll-to-top/` component + constants + `ui:` prefix +- [ ] Storybook stories (overflow, no overflow, clearance variants) +- [ ] `packages/ui/README.md` + barrel export +- [ ] Mount in `PublicCourse.PublicCourseShell` with `clearance="mobile-nav"` and a `label` prop on the shell +- [ ] Mount in `courses/[id]/+layout.svelte` with translated label and Ask AI clearance +- [ ] `common.scroll_to_top` + `pnpm translate` +- [ ] Confirm `AGENTS.md` still matches the real import path +- [ ] Manual pass: public lesson, public exercise, LMS contents list with many sections, LMS lesson, LMS exercise — button hidden at top, visible after ~1 screen, click returns to top, no overlap with BottomNav / Ask AI + +## Testing notes + +- Public course: `http://localhost:5173/course//lesson/?org=` (cloud VM: `?org=udemy-test`). +- LMS: log in (`admin@test.com` / `123456`), open a course → Lessons (contents) → a lesson → an exercise. +- Resize across the `lg` breakpoint on a public lesson: button must clear the mobile bottom nav and sit at the default offset on desktop. +- `prefers-reduced-motion`: no enter animation; scroll is instant.