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>
This commit is contained in:
Rotimi Best
2026-08-16 22:49:40 +01:00
committed by GitHub
co-authored by Cursor Agent
parent 1ffdd4bbae
commit 4aaa03c81b
3 changed files with 265 additions and 0 deletions
+235
View File
@@ -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 `<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.