{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "greeting",
  "title": "时段问候",
  "description": "按访客本地时间问候：时段图标与色调、可配置的分段时间与称呼；附带四时段指示条和会轮换副标题的问候卡片，首屏用静态文案避免水合差异。",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "path": "src/registry/ui/greeting/greeting.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Moon, Sun, Sunrise, Sunset, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useNow } from \"@/hooks/use-now\"\n\nimport { DEFAULT_LABELS, type GreetingLabels } from \"./config\"\nimport {\n  PERIODS,\n  normalizeBoundaries,\n  periodRange,\n  type GreetingBoundaries,\n  type GreetingPeriod,\n} from \"./period\"\nimport { useGreetingPeriod } from \"./use-greeting-period\"\n\nconst PERIOD_ICONS: Record<GreetingPeriod, LucideIcon> = {\n  morning: Sunrise,\n  noon: Sun,\n  afternoon: Sunset,\n  evening: Moon,\n}\n\n// 每个时段一种强调色：日出琥珀、正午天蓝、日落玫红、夜晚紫。\n// 取自 flow-diagram 的多彩色变量；没有安装它们时回落到主题强调色。\nconst PERIOD_TONES: Record<GreetingPeriod, string> = {\n  morning: \"var(--flow-amber, var(--primary))\",\n  noon: \"var(--flow-blue, var(--primary))\",\n  afternoon: \"var(--flow-rose, var(--primary))\",\n  evening: \"var(--flow-violet, var(--primary))\",\n}\n\nfunction toneStyle(period: GreetingPeriod | null) {\n  return {\n    \"--greeting-tone\": period ? PERIOD_TONES[period] : \"var(--primary)\",\n  } as React.CSSProperties\n}\n\ntype GreetingTextProps = {\n  /**\n   * 首屏与挂载前显示的静态文案，需保持中性以避免水合差异\n   * @default \"你好\"\n   */\n  fallback?: string\n  /**\n   * 05:00–10:59 的问候语\n   * @default \"早上好\"\n   */\n  morning?: string\n  /**\n   * 11:00–13:59 的问候语\n   * @default \"中午好\"\n   */\n  noon?: string\n  /**\n   * 14:00–17:59 的问候语\n   * @default \"下午好\"\n   */\n  afternoon?: string\n  /**\n   * 18:00–次日 04:59 的问候语\n   * @default \"晚上好\"\n   */\n  evening?: string\n  /** 称呼，拼在问候语之后，例如 `Travins` */\n  name?: string\n  /**\n   * 问候语与称呼之间的分隔\n   * @default \"，\"\n   */\n  nameSeparator?: string\n  /**\n   * 各时段的起始小时，必须严格递增；传错时回落到默认划分\n   * @default { morning: 5, noon: 11, afternoon: 14, evening: 18 }\n   */\n  boundaries?: Partial<GreetingBoundaries>\n  /**\n   * 指定当前小时（可带小数）。传入后结果是确定的，不再等待客户端挂载，适合演示和测试\n   */\n  hour?: number\n}\n\ntype GreetingProps = Omit<React.ComponentProps<\"span\">, \"children\"> &\n  GreetingTextProps & {\n    /**\n     * 问候语之后拼接的文本\n     * @default \"，很高兴在这里遇见你。\"\n     */\n    suffix?: string\n    /**\n     * 在问候语前显示当前时段的图标（日出 / 太阳 / 日落 / 月亮）\n     * @default false\n     */\n    showIcon?: boolean\n  }\n\n/** 按时段选出问候语；时段未知（挂载前）时返回 `fallback`。 */\nfunction pickGreeting(\n  period: GreetingPeriod | null,\n  texts: Pick<\n    GreetingTextProps,\n    \"fallback\" | \"morning\" | \"noon\" | \"afternoon\" | \"evening\"\n  >,\n) {\n  const {\n    fallback = \"你好\",\n    morning = \"早上好\",\n    noon = \"中午好\",\n    afternoon = \"下午好\",\n    evening = \"晚上好\",\n  } = texts\n  if (period === null) return fallback\n  return { morning, noon, afternoon, evening }[period]\n}\n\n/** 根据访客当地时间问候，首屏使用一致的静态文案以避免水合差异。 */\nfunction Greeting({\n  fallback,\n  suffix = \"，很高兴在这里遇见你。\",\n  morning,\n  noon,\n  afternoon,\n  evening,\n  name,\n  nameSeparator = \"，\",\n  boundaries,\n  hour,\n  showIcon = false,\n  className,\n  ...props\n}: GreetingProps) {\n  const { period } = useGreetingPeriod(boundaries, hour)\n  const Icon = period ? PERIOD_ICONS[period] : null\n  return (\n    <span\n      data-slot=\"greeting\"\n      data-state={period === null ? \"pending\" : \"ready\"}\n      data-period={period ?? undefined}\n      className={className}\n      {...props}\n    >\n      {showIcon ? (\n        <span\n          data-slot=\"greeting-icon\"\n          style={toneStyle(period)}\n          className=\"mr-[0.4em] inline-block size-[1em] align-[-0.125em] text-(--greeting-tone)\"\n        >\n          {Icon ? <Icon aria-hidden=\"true\" className=\"size-full\" /> : null}\n        </span>\n      ) : null}\n      {pickGreeting(period, { fallback, morning, noon, afternoon, evening })}\n      {name ? `${nameSeparator}${name}` : null}\n      {suffix}\n    </span>\n  )\n}\n\ntype GreetingPeriodsProps = Omit<React.ComponentProps<\"ol\">, \"children\"> &\n  Pick<GreetingTextProps, \"boundaries\" | \"hour\"> & {\n    /**\n     * 在当前时段格里显示时段进度条\n     * @default true\n     */\n    showProgress?: boolean\n    /** 覆盖部分可见文案（用到 `periods`、`names`） */\n    labels?: Partial<GreetingLabels>\n  }\n\n/** 四个时段的指示条：当前时段亮起，格内细线表示这个时段已经过去多少。 */\nfunction GreetingPeriods({\n  boundaries,\n  hour,\n  showProgress = true,\n  labels,\n  className,\n  ...props\n}: GreetingPeriodsProps) {\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const normalized = normalizeBoundaries(boundaries)\n  const { period: active, progress } = useGreetingPeriod(boundaries, hour)\n  return (\n    <ol\n      data-slot=\"greeting-periods\"\n      aria-label={text.periods}\n      className={cn(\"grid grid-cols-4 gap-1.5\", className)}\n      {...props}\n    >\n      {PERIODS.map((period) => {\n        const Icon = PERIOD_ICONS[period]\n        const current = period === active\n        return (\n          <li\n            key={period}\n            data-slot=\"greeting-period\"\n            data-period={period}\n            data-active={current ? \"\" : undefined}\n            aria-current={current ? \"true\" : undefined}\n            style={toneStyle(period)}\n            className={cn(\n              \"relative flex flex-col items-center gap-1 overflow-hidden rounded-md border border-edge bg-background px-1 py-2 text-muted-foreground transition-colors\",\n              \"data-[active]:border-(--greeting-tone)/60 data-[active]:bg-(--greeting-tone)/10 data-[active]:text-foreground\",\n            )}\n          >\n            <Icon\n              aria-hidden=\"true\"\n              strokeWidth={1.5}\n              className=\"size-4 in-data-[active]:text-(--greeting-tone)\"\n            />\n            <span className=\"text-xs\">{text.names[period]}</span>\n            <span className=\"font-mono text-[10px] opacity-70\">\n              {periodRange(period, normalized)}\n            </span>\n            {showProgress && current ? (\n              <span\n                aria-hidden=\"true\"\n                data-slot=\"greeting-progress\"\n                className=\"absolute inset-x-0 bottom-0 h-0.5 bg-(--greeting-tone)/20\"\n              >\n                <span\n                  className=\"block h-full origin-left bg-(--greeting-tone)\"\n                  style={{ transform: `scaleX(${progress})` }}\n                />\n              </span>\n            ) : null}\n          </li>\n        )\n      })}\n    </ol>\n  )\n}\n\ntype GreetingCardProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  Omit<GreetingTextProps, \"nameSeparator\"> & {\n    /**\n     * 显示四时段指示条\n     * @default true\n     */\n    showPeriods?: boolean\n    /**\n     * 副标题每 6 秒轮换一次；系统开启“减少动态效果”时固定显示第一条\n     * @default true\n     */\n    rotate?: boolean\n    /** 覆盖部分可见文案 */\n    labels?: Partial<GreetingLabels>\n  }\n\n/** 问候卡片：时段图标与色调、大字问候、轮换的副标题，以及四时段指示。 */\nfunction GreetingCard({\n  fallback,\n  morning,\n  noon,\n  afternoon,\n  evening,\n  name,\n  boundaries,\n  hour,\n  showPeriods = true,\n  rotate = true,\n  labels,\n  className,\n  ...props\n}: GreetingCardProps) {\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const { period } = useGreetingPeriod(boundaries, hour)\n  const reduced = useReducedMotion()\n  const tick = useNow(6000)\n  const messages = period ? text.messages[period] : []\n  const index =\n    rotate && !reduced && tick !== null && messages.length\n      ? Math.floor(tick / 6000) % messages.length\n      : 0\n  const message = messages[index] ?? text.fallbackMessage\n  const Icon = period ? PERIOD_ICONS[period] : null\n\n  return (\n    <div\n      data-slot=\"greeting-card\"\n      data-state={period === null ? \"pending\" : \"ready\"}\n      data-period={period ?? undefined}\n      style={toneStyle(period)}\n      className={cn(\n        \"relative flex flex-col gap-5 overflow-hidden rounded-lg border border-edge bg-background p-5\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute -top-16 -right-12 size-48 rounded-full bg-(--greeting-tone)/10 blur-3xl\"\n      />\n      {Icon ? (\n        <Icon\n          aria-hidden=\"true\"\n          strokeWidth={1}\n          className=\"pointer-events-none absolute top-3 right-3 size-16 text-(--greeting-tone)/50\"\n        />\n      ) : null}\n      <div className=\"relative\">\n        <p className=\"flex items-center gap-2 font-mono text-[10px] tracking-[0.08em] text-muted-foreground\">\n          <span\n            aria-hidden=\"true\"\n            className=\"size-1.5 rounded-full bg-(--greeting-tone)\"\n          />\n          {text.eyebrow} / {period ? text.tags[period] : text.pending}\n        </p>\n        <p className=\"mt-3 pr-14 text-4xl font-semibold tracking-tight\">\n          <Greeting\n            fallback={fallback}\n            morning={morning}\n            noon={noon}\n            afternoon={afternoon}\n            evening={evening}\n            name={name}\n            boundaries={boundaries}\n            hour={hour}\n            suffix=\"\"\n          />\n          <span className=\"text-(--greeting-tone)\">.</span>\n        </p>\n        <div className=\"mt-2 min-h-5 text-sm text-muted-foreground\">\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.p\n              key={message}\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -4 }}\n              transition={{ duration: 0.2 }}\n            >\n              {message}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n      </div>\n      {showPeriods ? (\n        <GreetingPeriods\n          boundaries={boundaries}\n          hour={hour}\n          labels={labels}\n          className=\"relative\"\n        />\n      ) : null}\n    </div>\n  )\n}\n\nexport { Greeting, GreetingCard, GreetingPeriods, useGreetingPeriod }\nexport type {\n  GreetingBoundaries,\n  GreetingCardProps,\n  GreetingLabels,\n  GreetingPeriod,\n  GreetingPeriodsProps,\n  GreetingProps,\n}\n",
      "type": "registry:ui",
      "target": "@ui/greeting/greeting.tsx"
    },
    {
      "path": "src/registry/ui/greeting/use-greeting-period.ts",
      "content": "\"use client\"\n\nimport { useNow } from \"@/hooks/use-now\"\n\nimport {\n  normalizeBoundaries,\n  resolvePeriod,\n  type GreetingBoundaries,\n  type GreetingPeriod,\n} from \"./period\"\n\ntype GreetingState = {\n  /** 当前时段；服务端与水合期间为 `null` */\n  period: GreetingPeriod | null\n  /** 当前时段已经过去的比例（0–1） */\n  progress: number\n}\n\n/**\n * 访客本地时间所在的时段，每分钟校准一次。\n * 传入 `hour` 时直接用它（可带小数，如 `13.5`），此时结果是确定的，服务端也能渲染。\n */\nfunction useGreetingPeriod(\n  boundaries?: Partial<GreetingBoundaries>,\n  hour?: number,\n): GreetingState {\n  const now = useNow(60_000)\n  const normalized = normalizeBoundaries(boundaries)\n  if (hour !== undefined)\n    return resolvePeriod(((hour % 24) + 24) % 24, normalized)\n  if (now === null) return { period: null, progress: 0 }\n  const date = new Date(now)\n  return resolvePeriod(date.getHours() + date.getMinutes() / 60, normalized)\n}\n\nexport { useGreetingPeriod }\nexport type { GreetingState }\n",
      "type": "registry:ui",
      "target": "@ui/greeting/use-greeting-period.ts"
    },
    {
      "path": "src/registry/ui/greeting/period.ts",
      "content": "// 时段划分：纯函数，不依赖 React 和当前时间，方便测试与服务端使用。\n\ntype GreetingPeriod = \"morning\" | \"noon\" | \"afternoon\" | \"evening\"\n\n/** 每个时段的起始小时（0–23）。`evening` 持续到次日 `morning` 开始。 */\ntype GreetingBoundaries = Record<GreetingPeriod, number>\n\nconst PERIODS: readonly GreetingPeriod[] = [\n  \"morning\",\n  \"noon\",\n  \"afternoon\",\n  \"evening\",\n]\n\nconst DEFAULT_BOUNDARIES: GreetingBoundaries = {\n  morning: 5,\n  noon: 11,\n  afternoon: 14,\n  evening: 18,\n}\n\n/** 起始小时必须严格递增且落在 0–23；传错时回落到默认划分，而不是渲染出错乱的时段。 */\nfunction normalizeBoundaries(\n  boundaries?: Partial<GreetingBoundaries>,\n): GreetingBoundaries {\n  const merged = { ...DEFAULT_BOUNDARIES, ...boundaries }\n  const hours = PERIODS.map((period) => merged[period])\n  const valid = hours.every(\n    (hour, index) =>\n      Number.isFinite(hour) &&\n      hour >= 0 &&\n      hour < 24 &&\n      (index === 0 || hour > (hours[index - 1] ?? Infinity)),\n  )\n  return valid ? merged : DEFAULT_BOUNDARIES\n}\n\nconst NEXT_PERIOD: Record<GreetingPeriod, GreetingPeriod> = {\n  morning: \"noon\",\n  noon: \"afternoon\",\n  afternoon: \"evening\",\n  evening: \"morning\",\n}\n\n/** 时刻（小时，可带小数）所在的时段，以及已经过去的比例（0–1）。 */\nfunction resolvePeriod(\n  time: number,\n  boundaries: GreetingBoundaries,\n): { period: GreetingPeriod; progress: number } {\n  const shifted = time < boundaries.morning ? time + 24 : time\n  let period: GreetingPeriod = \"evening\"\n  for (const candidate of [...PERIODS].reverse()) {\n    period = candidate\n    if (!(shifted < boundaries[candidate])) break\n  }\n  const start = boundaries[period]\n  const end = boundaries[NEXT_PERIOD[period]] + (period === \"evening\" ? 24 : 0)\n  return {\n    period,\n    progress: Math.min(1, Math.max(0, (shifted - start) / (end - start))),\n  }\n}\n\n/** `05–11` 这样的区间文字；`evening` 的终点是次日 `morning` 的起点。 */\nfunction periodRange(period: GreetingPeriod, boundaries: GreetingBoundaries) {\n  const end = boundaries[NEXT_PERIOD[period]]\n  const pad = (hour: number) => String(hour).padStart(2, \"0\")\n  return `${pad(boundaries[period])}–${pad(end)}`\n}\n\nexport {\n  DEFAULT_BOUNDARIES,\n  PERIODS,\n  normalizeBoundaries,\n  periodRange,\n  resolvePeriod,\n}\nexport type { GreetingBoundaries, GreetingPeriod }\n",
      "type": "registry:ui",
      "target": "@ui/greeting/period.ts"
    },
    {
      "path": "src/registry/ui/greeting/config.ts",
      "content": "import type { GreetingPeriod } from \"./period\"\n\n/** 问候卡片与时段指示的可见文案；`labels` 按字段整体覆盖（`names` 等对象不做深合并）。 */\ntype GreetingLabels = {\n  /** 卡片顶部的小标题 */\n  eyebrow: string\n  /** 挂载前小标题后的占位 */\n  pending: string\n  /** 时段指示的读屏名称 */\n  periods: string\n  /** 各时段的短名称 */\n  names: Record<GreetingPeriod, string>\n  /** 各时段的英文标记，显示在小标题后 */\n  tags: Record<GreetingPeriod, string>\n  /** 各时段轮换的副标题，至少一条 */\n  messages: Record<GreetingPeriod, readonly string[]>\n  /** 挂载前的副标题 */\n  fallbackMessage: string\n}\n\nconst DEFAULT_LABELS: GreetingLabels = {\n  eyebrow: \"GREETING\",\n  pending: \"LOCAL TIME\",\n  periods: \"一天中的时段\",\n  names: { morning: \"早上\", noon: \"中午\", afternoon: \"下午\", evening: \"晚上\" },\n  tags: {\n    morning: \"MORNING\",\n    noon: \"NOON\",\n    afternoon: \"AFTERNOON\",\n    evening: \"EVENING\",\n  },\n  messages: {\n    morning: [\"新的一天，先从最重要的一件事开始。\", \"今天想先做点什么？\"],\n    noon: [\"记得吃饭，也给眼睛放个假。\", \"上午辛苦了，下午继续。\"],\n    afternoon: [\"喝杯水，起来走动一下。\", \"下午的专注力很宝贵。\"],\n    evening: [\"今天辛苦了，慢慢来。\", \"收个尾，早点休息。\"],\n  },\n  fallbackMessage: \"很高兴在这里遇见你。\",\n}\n\nexport { DEFAULT_LABELS }\nexport type { GreetingLabels }\n",
      "type": "registry:ui",
      "target": "@ui/greeting/config.ts"
    },
    {
      "path": "src/registry/hooks/use-now.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n// 全页面共用一个对齐整秒的定时器：多个时钟同时挂载也只有一个 setTimeout 在跑，\n// 并且所有实例的秒数在同一帧跳动。\nconst listeners = new Set<() => void>()\nlet timer: number | undefined\n\nfunction schedule() {\n  timer = window.setTimeout(\n    () => {\n      listeners.forEach((listener) => listener())\n      if (listeners.size) schedule()\n    },\n    1005 - (Date.now() % 1000),\n  ) // 多等 5ms，避免定时器提前触发时读到上一秒\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener)\n  if (listeners.size === 1) schedule()\n  return () => {\n    listeners.delete(listener)\n    if (!listeners.size) window.clearTimeout(timer)\n  }\n}\n\n/**\n * 当前时间的毫秒时间戳，按 `step` 对齐（默认每秒）。\n * 服务端渲染与水合期间返回 `null`，水合完成后才给出真实时间，\n * 静态导出的 HTML 因此永远不会带着过期的时间。\n *\n * @param step 对齐粒度（毫秒）；快照不变时组件不会重新渲染，所以只显示分钟的组件传 `60_000` 即可\n */\nfunction useNow(step = 1000): number | null {\n  return React.useSyncExternalStore(\n    subscribe,\n    () => Math.floor(Date.now() / step) * step,\n    () => null,\n  )\n}\n\nexport { useNow }\n",
      "type": "registry:ui",
      "target": "hooks/use-now.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "flow-blue": "oklch(0.56 0.2 258)",
      "flow-green": "oklch(0.62 0.16 150)",
      "flow-amber": "oklch(0.7 0.16 70)",
      "flow-rose": "oklch(0.62 0.21 5)",
      "flow-violet": "oklch(0.56 0.22 292)",
      "flow-cyan": "oklch(0.66 0.12 215)",
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "flow-blue": "oklch(0.72 0.15 255)",
      "flow-green": "oklch(0.77 0.17 152)",
      "flow-amber": "oklch(0.82 0.15 82)",
      "flow-rose": "oklch(0.74 0.16 8)",
      "flow-violet": "oklch(0.74 0.15 295)",
      "flow-cyan": "oklch(0.82 0.11 208)",
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
