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:
co-authored by
Cursor Agent
parent
1ffdd4bbae
commit
4aaa03c81b
@@ -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
|
||||
<script lang="ts">
|
||||
import { ScrollToTop } from '@cio/ui/custom/scroll-to-top';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
</script>
|
||||
|
||||
<ScrollToTop label={$t('common.scroll_to_top')} />
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user