Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

20 changes: 20 additions & 0 deletions app/_docs/demos/avatar-stack.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center gap-8">
<AvatarStack people={people.slice(0, 3)} />
<AvatarStack people={people} />
</div>
)
}
13 changes: 13 additions & 0 deletions app/_docs/demos/countdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center gap-4 text-title font-medium">
<Countdown to={later} />
<Countdown to={soon} />
</div>
)
}
51 changes: 51 additions & 0 deletions app/_docs/demos/expand-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client"

import { PencilIcon } from "lucide-react"

import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack"
import { ExpandRow } from "@/registry/winlab/blocks/expand-row/expand-row"
import { Button } from "@/registry/winlab/ui/button"

const weeks = [
{ date: "10 月 19 日", who: "博士生", what: "NetLLM (SIGCOMM'24)" },
{ date: "11 月 2 日", who: "碩士生", what: "ORANSlice (MobiCom'24)" },
{ date: "11 月 9 日", who: "專題生", what: "XRP (OSDI'22)" },
]

export function Demo() {
return (
<ul className="flex w-full max-w-2xl stagger-rise flex-col text-left">
{weeks.map((week) => (
<ExpandRow
key={week.date}
summary={
<div className="flex gap-4">
<span className="w-28 shrink-0 tabular-nums">{week.date}</span>
<span className="font-medium">{week.who}</span>
<span className="min-w-0 truncate text-muted-foreground">
{week.what}
</span>
</div>
}
actions={
<Button variant="ghost" size="icon" aria-label="編輯">
<PencilIcon />
</Button>
}
>
<div className="flex flex-wrap items-center gap-4 pl-32 text-muted-foreground">
<AvatarStack
people={[
{ id: "a", name: "教授" },
{ id: "b", name: "助理" },
{ id: "c", name: "校友" },
]}
/>
<span>提問</span>
<span>EC 411 15:30</span>
</div>
</ExpandRow>
))}
</ul>
)
}
44 changes: 44 additions & 0 deletions app/_docs/demos/focus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client"

import { AvatarStack } from "@/registry/winlab/blocks/avatar-stack/avatar-stack"
import { Countdown } from "@/registry/winlab/blocks/countdown/countdown"
import {
Focus,
FocusHighlight,
FocusLabel,
FocusMeta,
FocusTitle,
} from "@/registry/winlab/blocks/focus/focus"
import { PageHeader } from "@/registry/winlab/blocks/page-header/page-header"

const askers = [
{ id: "1", name: "博士生" },
{ id: "2", name: "碩士生" },
{ id: "3", name: "專題生" },
]

// Three days and a bit from whenever the page is opened.
const next = new Date(Date.now() + (3 * 1440 + 19 * 60 + 37) * 60 * 1000)

export function Demo() {
return (
<div className="flex w-full max-w-2xl flex-col gap-12 text-left">
<PageHeader title="實驗室會議" />
<Focus>
<FocusLabel>下一場</FocusLabel>
<FocusTitle>
<FocusHighlight>
<Countdown to={next} />
</FocusHighlight>{" "}
後,碩士生報告
</FocusTitle>
<p>Efficient Memory Management for LLM Serving (SOSP&apos;23)</p>
<FocusMeta>
<AvatarStack people={askers} />
<span>提問</span>
<span>EC 411 15:30</span>
</FocusMeta>
</Focus>
</div>
)
}
10 changes: 10 additions & 0 deletions app/_docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import { Demo as Separator } from "@/app/_docs/demos/separator"
import { Demo as Collapsible } from "@/app/_docs/demos/collapsible"
import { Demo as Skeleton } from "@/app/_docs/demos/skeleton"
import { Demo as Sonner } from "@/app/_docs/demos/sonner"
import { Demo as FocusDemo } from "@/app/_docs/demos/focus"
import { Demo as AvatarStackDemo } from "@/app/_docs/demos/avatar-stack"
import { Demo as CountdownDemo } from "@/app/_docs/demos/countdown"
import { Demo as ExpandRowDemo } from "@/app/_docs/demos/expand-row"
import { Demo as NumberTicker } from "@/app/_docs/demos/number-ticker"

export const demos: Record<string, ComponentType> = {
colors: Colors,
Expand Down Expand Up @@ -78,4 +83,9 @@ export const demos: Record<string, ComponentType> = {
collapsible: Collapsible,
skeleton: Skeleton,
sonner: Sonner,
focus: FocusDemo,
"avatar-stack": AvatarStackDemo,
countdown: CountdownDemo,
"expand-row": ExpandRowDemo,
"number-ticker": NumberTicker,
}
26 changes: 26 additions & 0 deletions app/_docs/demos/number-ticker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client"

import * as React from "react"

import { Button } from "@/registry/winlab/ui/button"
import { NumberTicker } from "@/registry/winlab/ui/number-ticker"

export function Demo() {
const [value, setValue] = React.useState(128)
return (
<div className="flex flex-col items-center gap-6">
<p className="text-title font-medium">
NT$ <NumberTicker value={value} />
</p>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setValue((v) => Math.max(0, v - 35))}
>
減一個便當
</Button>
<Button onClick={() => setValue((v) => v + 35)}>加一個便當</Button>
</div>
</div>
)
}
35 changes: 35 additions & 0 deletions app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ export const docs: Doc[] = [
description: "清單沒有資料或搜尋沒有結果時,統一的一句話與下一步。",
item: "empty-state",
},
{
group: "區塊",
slug: "focus",
title: "焦點",
description: "每頁一個大聲的地方,其他都安靜:標籤、標題與重點、一列資訊。",
item: "focus",
},
{
group: "區塊",
slug: "avatar-stack",
title: "頭像疊",
description: "多個人放在一個人的位置,指到時展開。",
item: "avatar-stack",
},
{
group: "區塊",
slug: "countdown",
title: "倒數",
description: "距離某個時間還有多久,到分鐘,數字會滾動。",
item: "countdown",
},
{
group: "區塊",
slug: "expand-row",
title: "展開列",
description: "清單的一列原地展開細節,不換頁也不開對話框。",
item: "expand-row",
},
{
group: "區塊",
slug: "page-header",
Expand Down Expand Up @@ -244,6 +272,13 @@ export const docs: Doc[] = [
description: "成員頭像,沒有圖時顯示名字的第一個字。",
item: "avatar",
},
{
group: "元件",
slug: "number-ticker",
title: "數字滾動",
description: "整數變動時,每一位數字滾到新的值。",
item: "number-ticker",
},
{
group: "元件",
slug: "tooltip",
Expand Down
94 changes: 94 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,94 @@
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}

/* The page's one focus moves on a spring: 500ms, overshooting 10%. */
@utility duration-spring {
--tw-duration: 500ms;
transition-duration: 500ms;
--tw-ease: linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1);
transition-timing-function: linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1);
}

/* Arriving on the page: the focus rises on the spring, a list's rows
rise one after another, 30ms apart, quietly. */
@keyframes rise {
from {
opacity: 0;
translate: 0 0.5rem;
}
}

@keyframes appear {
from {
opacity: 0;
}
}

@utility animate-rise {
animation: rise 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1) both;
}

@utility stagger-rise {
& > * {
animation: rise 200ms cubic-bezier(0, 0, 0.2, 1) both;
}
& > :nth-child(2) {
animation-delay: 30ms;
}
& > :nth-child(3) {
animation-delay: 60ms;
}
& > :nth-child(4) {
animation-delay: 90ms;
}
& > :nth-child(5) {
animation-delay: 120ms;
}
& > :nth-child(6) {
animation-delay: 150ms;
}
& > :nth-child(7) {
animation-delay: 180ms;
}
& > :nth-child(8) {
animation-delay: 210ms;
}
& > :nth-child(n + 9) {
animation-delay: 240ms;
}
}

/* A number-ticker digit column; data-digit picks the row. */
@utility digit-roll {
transition: translate 500ms linear(0, 0.04, 0.142, 0.278, 0.429, 0.578, 0.715, 0.833, 0.928, 1.001, 1.051, 1.083, 1.099, 1.103, 1.097, 1.086, 1.072, 1.056, 1.041, 1.027, 1.016, 1.006, 0.999, 0.994, 1);
&[data-digit="1"] {
translate: 0 -10%;
}
&[data-digit="2"] {
translate: 0 -20%;
}
&[data-digit="3"] {
translate: 0 -30%;
}
&[data-digit="4"] {
translate: 0 -40%;
}
&[data-digit="5"] {
translate: 0 -50%;
}
&[data-digit="6"] {
translate: 0 -60%;
}
&[data-digit="7"] {
translate: 0 -70%;
}
&[data-digit="8"] {
translate: 0 -80%;
}
&[data-digit="9"] {
translate: 0 -90%;
}
}
/* Reduced motion keeps the fades and drops every zoom and slide. */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
Expand All @@ -86,6 +174,12 @@
--tw-exit-translate-x: 0 !important;
--tw-exit-translate-y: 0 !important;
}
.animate-rise, .stagger-rise > * {
animation-name: appear;
}
.digit-roll {
transition: none;
}
}

/* Menus with p-1 sit 4px outside their trigger on both sides. */
Expand Down
Loading
Loading