Files
classroomio/prd/scroll-to-top/README.md
T
Rotimi BestandCursor Agent 4aaa03c81b docs: add scroll-to-top spec and agent convention (#917)
Define a shared back-to-top control for overflowing course pages and require it in AGENTS.md so long-content surfaces reuse one component.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-16 22:49:40 +01:00

236 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<svelte:window onscroll>` / `<svelte:window onresize>` 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 `<main>`). 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/<slug>/lesson/<itemSlug>?org=<siteName>` (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.