diff --git a/DESIGN.md b/DESIGN.md
index 1236ad5..6c846e1 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -36,6 +36,16 @@ Use `wide` only when the content is a grid that loses meaning when squeezed; tex
In a spotlight, the title and description center above the focal content; text that is the focus itself stays left-aligned when it runs to more than one line.
+## Focus and quiet
+
+Every app page has one loud thing and keeps the rest quiet.
+
+- **The focus** is what a member opened the page for: the next meeting, today's lunch order, what waits on their signature. It sits right under the page header, in the `focus` block, and nowhere else on the page.
+- Only the focus may use the primary color for text (`FocusHighlight`), the spring (`duration-spring`, `animate-rise`) and the large type of `FocusTitle`. Numbers in it roll (`number-ticker`, `countdown`); people in it show as an `avatar-stack`.
+- **Everything else is quiet**: lists in `text-body`, secondary facts in `muted-foreground`, motion at `duration-state` / `duration-overlay`. Lists may arrive with `stagger-rise`.
+- A page with nothing pressing (a settings page, a list of records) has no focus. Do not invent one.
+- A row that has more to say opens in place (`expand-row`) instead of sending the member to another page; editing still happens in a dialog.
+
## Two layers
The interface is flat and has exactly two layers.
@@ -126,16 +136,24 @@ Focus follows the component's own color. `--ring` is a neutral gray, so inputs,
## Motion
-Two durations, both ease-out:
+Quiet things move briefly and ease out; the page's one focus moves on a spring.
| Utility | Duration | Use |
|---------|----------|-----|
| `duration-state` | 150ms | A control changing state: hover, focus, check, switch, the tab highlight sliding |
| `duration-overlay` | 200ms | Something appearing or leaving: dialogs, menus, popovers, tooltips, a collapsible opening |
+| `duration-spring` | 500ms, overshooting 10% | Only what the page is about: the `focus` block, digits rolling, an avatar stack spreading, a row opening in place |
+
+| Utility | What it does |
+|---------|--------------|
+| `animate-rise` | The focus rises 8px into place on the spring when the page loads |
+| `stagger-rise` | On a list: its rows rise one after another, 30ms apart, in 200ms each, so a list arrives as a sequence and not as one block |
- Every animation runs the same way in both directions: what zooms in zooms out, what fades in fades out, at the same duration.
- Overlays fade and zoom from where they open; a select menu that sits over its trigger only fades, opening and closing.
- Menu rows highlight instantly, so keyboard navigation never lags.
-- When the system asks for reduced motion (`prefers-reduced-motion: reduce`), every zoom and slide is dropped and the fades stay; sliding highlights and height animations jump instead.
+- The spring is for the focus only. A spring on every button makes the page loud everywhere, which is the same as nowhere.
+- When the system asks for reduced motion (`prefers-reduced-motion: reduce`), every zoom and slide is dropped and the fades stay; rising becomes fading, digits change without rolling, sliding highlights and height animations jump instead.
- `tokens:check` rejects other durations (`duration-300`), easing classes and delays.
- Toasts keep sonner's own timing, which also follows reduced motion.
+
diff --git a/app/_docs/demos/avatar-stack.tsx b/app/_docs/demos/avatar-stack.tsx
new file mode 100644
index 0000000..c82a115
--- /dev/null
+++ b/app/_docs/demos/avatar-stack.tsx
@@ -0,0 +1,20 @@
+import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack"
+
+const people = [
+ "教授",
+ "博士生",
+ "碩士生",
+ "專題生",
+ "助理",
+ "訪問學者",
+ "校友",
+].map((name, index) => ({ id: String(index), name }))
+
+export function Demo() {
+ return (
+
+
+
+
+ )
+}
diff --git a/app/_docs/demos/countdown.tsx b/app/_docs/demos/countdown.tsx
new file mode 100644
index 0000000..7a5497a
--- /dev/null
+++ b/app/_docs/demos/countdown.tsx
@@ -0,0 +1,13 @@
+import { Countdown } from "@/registry/winlab/blocks/countdown/countdown"
+
+const soon = new Date(Date.now() + 42 * 60 * 1000)
+const later = new Date(Date.now() + (2 * 1440 + 5 * 60) * 60 * 1000)
+
+export function Demo() {
+ return (
+
+ )
+}
+
+export { AvatarStack }
diff --git a/registry/winlab/blocks/countdown/countdown.tsx b/registry/winlab/blocks/countdown/countdown.tsx
new file mode 100644
index 0000000..2d2356a
--- /dev/null
+++ b/registry/winlab/blocks/countdown/countdown.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import * as React from "react"
+
+import { NumberTicker } from "@/registry/winlab/ui/number-ticker"
+
+const MINUTE = 60 * 1000
+const TICK = 15 * 1000
+
+// The clock, read every 15 seconds; null on the server, so the first client
+// render matches it and nothing ticks until mounted.
+function subscribe(update: () => void) {
+ const timer = setInterval(update, TICK)
+ return () => clearInterval(timer)
+}
+const readClock = () => Math.floor(Date.now() / TICK) * TICK
+const serverClock = () => null
+
+function parts(ms: number) {
+ const minutes = Math.max(0, Math.ceil(ms / MINUTE))
+ return {
+ days: Math.floor(minutes / 1440),
+ hours: Math.floor((minutes % 1440) / 60),
+ minutes: minutes % 60,
+ }
+}
+
+// Time left until a moment, to the minute: "3 天 19 小時 37 分". Units that
+// are zero at the front drop out; the digits roll as the minutes pass. Once
+// the moment arrives it shows children instead (for example "進行中").
+function Countdown({
+ to,
+ children = "現在",
+}: {
+ /** The moment, as an ISO string with its offset or a Date. */
+ to: string | Date
+ children?: React.ReactNode
+}) {
+ const target = new Date(to).getTime()
+ const now = React.useSyncExternalStore(subscribe, readClock, serverClock)
+
+ const left = target - (now ?? target - 1)
+ if (now !== null && left <= 0) return <>{children}>
+ const { days, hours, minutes } = parts(left)
+ const units = [
+ { value: days, unit: "天", show: days > 0 },
+ { value: hours, unit: "小時", show: days > 0 || hours > 0 },
+ { value: minutes, unit: "分", show: true },
+ ].filter((part) => part.show)
+
+ return (
+
+ {now === null ? (
+ 0 分
+ ) : (
+ units.map((part) => (
+
+
+ {part.unit}
+
+ ))
+ )}
+
+ )
+}
+
+export { Countdown }
diff --git a/registry/winlab/blocks/expand-row/expand-row.tsx b/registry/winlab/blocks/expand-row/expand-row.tsx
new file mode 100644
index 0000000..c66cdd3
--- /dev/null
+++ b/registry/winlab/blocks/expand-row/expand-row.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
+import { ChevronDownIcon } from "lucide-react"
+import * as React from "react"
+
+import { cn } from "@/registry/winlab/lib/utils"
+
+// A list row that opens in place to show more, instead of going to another
+// page or a dialog. The summary is the row as the list shows it; the details
+// unfold under it on the spring. Row actions sit beside the summary, outside
+// the toggle, so pressing one never opens the row.
+function ExpandRow({
+ summary,
+ actions,
+ children,
+ defaultOpen,
+ className,
+}: {
+ summary: React.ReactNode
+ actions?: React.ReactNode
+ /** The details. */
+ children: React.ReactNode
+ defaultOpen?: boolean
+ className?: string
+}) {
+ return (
+ }
+ className={cn("border-b border-border", className)}
+ >
+
+
+
{summary}
+
+
+ {actions &&
{actions}
}
+
+
+
{children}
+
+
+ )
+}
+
+export { ExpandRow }
diff --git a/registry/winlab/blocks/focus/focus.tsx b/registry/winlab/blocks/focus/focus.tsx
new file mode 100644
index 0000000..d622b18
--- /dev/null
+++ b/registry/winlab/blocks/focus/focus.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+
+import { cn } from "@/registry/winlab/lib/utils"
+
+// The one loud thing on a page: what the member came to see first (the next
+// meeting, today's order, what waits on them). It rises in on the spring and
+// is the only place a page uses the primary color for text. Everything
+// below it stays quiet. One per page, right under the page header.
+function Focus({ className, ...props }: React.ComponentProps<"section">) {
+ return (
+
+ )
+}
+
+// What the focus is about, in a word or two: "下一場", "今天".
+function FocusLabel({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+// The loud line itself. Wrap the part that matters most in FocusHighlight.
+function FocusTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return (
+
+ )
+}
+
+function FocusHighlight({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+// A row of facts under the title: who, where, when, an avatar stack.
+function FocusMeta({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Focus, FocusHighlight, FocusLabel, FocusMeta, FocusTitle }
diff --git a/registry/winlab/ui/number-ticker.tsx b/registry/winlab/ui/number-ticker.tsx
new file mode 100644
index 0000000..30da74e
--- /dev/null
+++ b/registry/winlab/ui/number-ticker.tsx
@@ -0,0 +1,44 @@
+import { cn } from "@/registry/winlab/lib/utils"
+
+const digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
+
+// A number whose digits roll to their new value on the spring. Each digit is
+// a column of 0 to 9 clipped to one line; data-digit slides it. Screen
+// readers get the plain number.
+function NumberTicker({
+ value,
+ className,
+}: {
+ /** A whole number, 0 or more. */
+ value: number
+ className?: string
+}) {
+ const text = String(Math.max(0, Math.round(value)))
+ return (
+
+ {text}
+ {text.split("").map((digit, index) => (
+
+
+ {digits.map((row) => (
+
+ {row}
+
+ ))}
+
+
+ ))}
+
+ )
+}
+
+export { NumberTicker }