{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-scroll-progress",
  "type": "registry:component",
  "title": "Scroll progress",
  "description": "A reading line for how far through content a reader is",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2",
    "react@^19.2.4"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-primitives.json",
    "https://gpuixui.sudokw.com/gpuix-tokens.json",
    "https://gpuixui.sudokw.com/gpuix-tw.json"
  ],
  "files": [
    {
      "path": "packages/components/src/internal/recipe.ts",
      "target": "@ui/gpuixui/components/internal/recipe.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport { createRecipeCache } from \"../../tw/index.js\";\nimport type { Theme } from \"./theme.js\";\n\nexport const recipeStyle: (recipe: string, theme: Theme) => StyleDesc =\n  createRecipeCache(512);\n"
    },
    {
      "path": "packages/components/src/internal/scroll-surface.tsx",
      "target": "@ui/gpuixui/components/internal/scroll-surface.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { EventPayload, StyleDesc } from \"@gpuix/react\";\nimport { mergeStyles } from \"@gpuix/react/floating\";\nimport { ScrollArea } from \"../../primitives/scroll-area.js\";\nimport { cn } from \"../../tw/index.js\";\nimport { useState, type ReactNode } from \"react\";\nimport { scrollMetrics, type ScrollMetrics } from \"../scroll/metrics.js\";\nimport { recipeStyle } from \"./recipe.js\";\nimport { useTheme } from \"./theme.js\";\n\ninterface ScrollReading {\n  readonly metrics: ScrollMetrics;\n  readonly noteScroll: (event: EventPayload) => void;\n  readonly noteViewport: (event: EventPayload) => void;\n  readonly noteContent: (event: EventPayload) => void;\n}\n\nconst useScrollReading = (): ScrollReading => {\n  const [offsetY, setOffsetY] = useState(0);\n  const [viewportHeight, setViewportHeight] = useState(0);\n  const [contentHeight, setContentHeight] = useState(0);\n  return {\n    metrics: scrollMetrics({ offsetY, viewportHeight, contentHeight }),\n    noteScroll: (event) => setOffsetY(event.scrollY ?? 0),\n    noteViewport: (event) => setViewportHeight(event.height ?? 0),\n    noteContent: (event) => setContentHeight(event.height ?? 0),\n  };\n};\n\nexport interface ScrollSurfaceProps {\n  readonly children?: ReactNode;\n\n  readonly chrome?: (metrics: ScrollMetrics) => ReactNode;\n\n  readonly testId?: string;\n\n  readonly label?: string;\n\n  readonly onScroll?: (event: EventPayload) => void;\n\n  readonly onScrollChange?: (event: EventPayload) => void;\n\n  readonly onLayout?: (event: EventPayload) => void;\n\n  readonly className?: string;\n\n  readonly style?: StyleDesc;\n}\n\nexport const ScrollSurface = ({\n  children,\n  chrome,\n  testId,\n  label,\n  onScroll,\n  onScrollChange,\n  onLayout,\n  className,\n  style,\n}: ScrollSurfaceProps) => {\n  const theme = useTheme();\n  const reading = useScrollReading();\n\n  return (\n    <div\n      style={mergeStyles(\n        recipeStyle(cn(\"relative flex flex-col min-h-0\", className), theme),\n        style,\n      )}\n    >\n      <ScrollArea\n        axis=\"vertical\"\n        viewportTestId={testId}\n        label={label}\n        scrollbarColor={theme.colors.scrollbar}\n        onScroll={onScroll}\n        onScrollChange={(event) => {\n          reading.noteScroll(event);\n          onScrollChange?.(event);\n        }}\n        onLayout={(event) => {\n          reading.noteViewport(event);\n          onLayout?.(event);\n        }}\n        style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }}\n      >\n        <div onLayout={reading.noteContent} style={{ flexShrink: 0 }}>\n          {children}\n        </div>\n      </ScrollArea>\n      {chrome === undefined ? null : chrome(reading.metrics)}\n    </div>\n  );\n};\n"
    },
    {
      "path": "packages/components/src/internal/text.ts",
      "target": "@ui/gpuixui/components/internal/text.ts",
      "type": "registry:lib",
      "content": "import { pipe } from \"effect\";\nimport * as Option from \"effect/Option\";\nimport * as Str from \"effect/String\";\n\nexport const textOf = (value: unknown): string =>\n  typeof value === \"string\" ? value : \"\";\n\nexport const nameOf = (value: unknown): string | undefined =>\n  pipe(\n    Option.some(textOf(value)),\n    Option.map(Str.trim),\n    Option.filter(Str.isNonEmpty),\n    Option.getOrUndefined,\n  );\n"
    },
    {
      "path": "packages/components/src/internal/theme.ts",
      "target": "@ui/gpuixui/components/internal/theme.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport * as Schema from \"effect/Schema\";\nimport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  brandTextOn,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  pointerWash,\n  selectionOn,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n  type BoxShadow,\n  type ElevationLevel,\n  type FindHighlightSource,\n  type NativeTextFill,\n  type PlatformName,\n  type PointerWashSurface,\n  type StatusTone,\n  type SyntaxColors,\n  type SyntaxRole,\n  type TextRole,\n  type Theme,\n  type ThemeColors,\n} from \"../../tokens/index.js\";\n\nexport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n};\nexport type {\n  BoxShadow,\n  ElevationLevel,\n  FindHighlightSource,\n  NativeTextFill,\n  PlatformName,\n  StatusTone,\n  SyntaxColors,\n  SyntaxRole,\n  TextRole,\n  Theme,\n};\nexport { ThemeProvider, useTheme } from \"../../tokens/react.js\";\n\nexport const keyboardRing = (theme: Theme, invalid = false): StyleDesc =>\n  controlFocusRing(theme, { invalid }) ?? {};\n\nexport interface ControlRingState {\n  readonly disabled?: boolean;\n\n  readonly invalid?: boolean;\n}\n\nexport const controlRing = (\n  theme: Theme,\n  state: ControlRingState = {},\n): StyleDesc => controlFocusRing(theme, state) ?? {};\n\nexport const pressWash = (theme: Theme, replaced: PointerWashSurface): string =>\n  pointerWash(theme, \"active\", replaced) ?? theme.colors.active;\n\nexport const selectionWash = (theme: Theme): StyleDesc => ({\n  selectionColor: theme.colors.selection,\n});\n\nexport const keepContent: StyleDesc = { keyboardFocusDim: false };\n\nexport const disabledFade = (\n  disabled: boolean,\n): { readonly opacity: number } | undefined =>\n  disabled ? { opacity: disabledOpacity } : undefined;\n\nconst Tones = Schema.Literals([\"info\", \"success\", \"warning\", \"danger\"]);\n\nexport type Tone = typeof Tones.Type;\n\nexport const toneOf = (value: unknown): Tone =>\n  Schema.is(Tones)(value) ? value : \"info\";\n\nexport interface ToneStyle {\n  readonly fg: string;\n  readonly bg: string;\n  readonly border: string;\n}\n\nconst TOKEN_TONE: Readonly<Record<Tone, StatusTone>> = {\n  info: \"info\",\n  success: \"success\",\n  warning: \"warning\",\n  danger: \"destructive\",\n};\n\nexport const tone = (theme: Theme, value: Tone): ToneStyle => {\n  const { background, border, foreground } = statusTone(\n    theme,\n    TOKEN_TONE[value],\n  );\n  return { bg: background, border, fg: foreground };\n};\n\nconst softRoles: Readonly<Record<Tone, keyof ThemeColors>> = {\n  info: \"infoSoft\",\n  success: \"successSoft\",\n  warning: \"warningSoft\",\n  danger: \"destructiveSoft\",\n};\n\nexport const messageLink = (theme: Theme, value: Tone): string =>\n  brandTextOn(theme, softRoles[value]);\n\nexport const messageSelection = (theme: Theme, value: Tone): string =>\n  selectionOn(theme, softRoles[value]);\n\nexport const toneSurface = (theme: Theme, value: Tone): StyleDesc => {\n  const { bg, border, fg } = tone(theme, value);\n  return {\n    backgroundColor: bg,\n    color: fg,\n    borderWidth: theme.borderWidth.thin,\n    borderColor: border,\n  };\n};\n"
    },
    {
      "path": "packages/components/src/scroll-progress.tsx",
      "target": "@ui/gpuixui/components/scroll-progress.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { StyleDesc } from \"@gpuix/react\";\nimport * as Option from \"effect/Option\";\nimport type { ReactNode } from \"react\";\nimport { ScrollSurface } from \"./internal/scroll-surface.js\";\nimport { nameOf } from \"./internal/text.js\";\nimport { useTheme } from \"./internal/theme.js\";\nimport { scrollSize, type ScrollMetrics } from \"./scroll/metrics.js\";\n\nexport type ScrollProgressSide = \"top\" | \"bottom\";\n\nexport interface ScrollProgressProps {\n  readonly children?: ReactNode;\n\n  readonly testId?: string;\n\n  readonly side?: ScrollProgressSide;\n\n  readonly thickness?: number;\n\n  readonly color?: string;\n\n  readonly trackColor?: string;\n\n  readonly label?: string;\n\n  readonly className?: string;\n\n  readonly style?: StyleDesc;\n}\n\nconst percent = (metrics: ScrollMetrics): number =>\n  Math.round(metrics.progress * 100);\n\nconst Line = ({\n  metrics,\n  side,\n  thickness,\n  color,\n  trackColor,\n  label,\n  base,\n}: {\n  readonly metrics: ScrollMetrics;\n  readonly side: ScrollProgressSide;\n  readonly thickness: number;\n  readonly color: string;\n  readonly trackColor: string;\n  readonly label: string | undefined;\n  readonly base: string;\n}) => (\n  <div\n    testId={`${base}-track`}\n    role=\"progressbar\"\n    {...(label === undefined ? {} : { \"aria-label\": label })}\n    aria-valuetext={`${percent(metrics)}%`}\n    style={{\n      position: \"absolute\",\n      ...(side === \"top\" ? { top: 0 } : { bottom: 0 }),\n      left: 0,\n      right: 0,\n      height: thickness,\n      backgroundColor: trackColor,\n      overflow: \"hidden\",\n      pointerEvents: \"none\",\n    }}\n  >\n    <div\n      testId={`${base}-bar`}\n      style={{\n        height: \"100%\",\n        width: `${percent(metrics)}%`,\n        backgroundColor: color,\n      }}\n    />\n  </div>\n);\n\nexport const ScrollProgress = ({\n  children,\n  side = \"bottom\",\n  thickness,\n  color,\n  trackColor,\n  label,\n  className,\n  style,\n  testId,\n}: ScrollProgressProps) => {\n  const theme = useTheme();\n  const base = testId ?? \"scroll-progress\";\n  const name = nameOf(label);\n\n  return (\n    <ScrollSurface\n      testId={testId}\n      label={name}\n      className={className}\n      style={style}\n      chrome={(metrics) => (\n        <Line\n          metrics={metrics}\n          side={side}\n          thickness={scrollSize(Option.fromNullishOr(thickness), 3)}\n          color={color ?? theme.colors.primary}\n          trackColor={trackColor ?? theme.colors.muted}\n          label={name}\n          base={base}\n        />\n      )}\n    >\n      {children}\n    </ScrollSurface>\n  );\n};\n\nexport const scrollProgressMeta = {\n  name: \"Scroll progress\",\n  tier: 5,\n  status: \"done\",\n  gpuixSupport: \"partial\",\n  limitations: [\n    \"the line follows the offset instead of tweening to it: a scroll position is a direct reading\",\n    \"the region is vertical only, and an arrow key moves it by the primitives' row height\",\n    \"the runtime exposes no aria-valuenow, so the reading is announced through aria-valuetext\",\n  ],\n  docs: \"docs/scroll-progress.mdx\",\n  example: \"examples/scroll-progress.example.tsx\",\n  testFile: \"src/behaviour/scroll-progress.test.tsx\",\n} as const;\n"
    },
    {
      "path": "packages/components/src/scroll/metrics.ts",
      "target": "@ui/gpuixui/components/scroll/metrics.ts",
      "type": "registry:lib",
      "content": "import type { LinearGradientBackground } from \"@gpuix/react\";\nimport * as Option from \"effect/Option\";\n\nexport interface ScrollSample {\n  readonly offsetY: number;\n\n  readonly viewportHeight: number;\n\n  readonly contentHeight: number;\n}\n\nexport interface ScrollMetrics {\n  readonly progress: number;\n\n  readonly offsetY: number;\n\n  readonly scrollable: boolean;\n\n  readonly atStart: boolean;\n\n  readonly atEnd: boolean;\n}\n\nexport type ScrollEdge = \"top\" | \"bottom\";\n\nconst finite = (value: number, fallback: number): number =>\n  Number.isFinite(value) ? value : fallback;\n\nexport const scrollMetrics = (sample: ScrollSample): ScrollMetrics => {\n  const viewport = Math.max(0, finite(sample.viewportHeight, 0));\n  const content = Math.max(0, finite(sample.contentHeight, 0));\n  const offset = Math.min(0, finite(sample.offsetY, 0));\n  const overflow = Math.max(0, content - viewport);\n\n  const scrollable = overflow > 1;\n  const travelled = scrollable ? Math.max(0, Math.min(overflow, -offset)) : 0;\n\n  return {\n    progress: scrollable ? travelled / overflow : 0,\n\n    offsetY: travelled === 0 ? 0 : -travelled,\n    scrollable,\n    atStart: !scrollable || -offset <= 1,\n    atEnd: !scrollable || -offset >= overflow - 1,\n  };\n};\n\nexport interface EdgeFadeColors {\n  readonly solid: string;\n\n  readonly clear: string;\n}\n\nexport const edgeGradient = (\n  edge: ScrollEdge,\n  colors: EdgeFadeColors,\n): LinearGradientBackground => ({\n  type: \"linear-gradient\",\n  angle: edge === \"top\" ? 180 : 0,\n  stops: [\n    { color: colors.solid, position: 0 },\n    { color: colors.clear, position: 1 },\n  ],\n});\n\nexport const scrollSize = (\n  value: Option.Option<number>,\n  fallback: number,\n): number =>\n  Option.match(value, {\n    onNone: () => fallback,\n    onSome: (size) =>\n      Number.isFinite(size) && size > 0 ? Math.round(size) : fallback,\n  });\n"
    }
  ],
  "docs": "[Scroll progress](https://gpuixui.sudokw.com/components/scroll-progress) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/components/scroll-progress",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}