{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blackboard",
  "title": "Blackboard",
  "author": "ilinxa",
  "description": "Chalkboard-style team notes board — handwritten ink notes with pens, widths, pins, @mentions, auto-save, and lazy history loading.",
  "dependencies": [
    "@fontsource/kalam",
    "@fontsource-variable/caveat",
    "@fontsource/patrick-hand",
    "@fontsource/shadows-into-light",
    "date-fns",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "textarea"
  ],
  "files": [
    {
      "path": "src/registry/components/data/blackboard/blackboard.tsx",
      "content": "\"use client\";\n\nimport type { BlackboardProps } from \"./types\";\nimport { BlackboardRoot } from \"./parts/blackboard-root\";\nimport { BlackboardSurface } from \"./parts/blackboard-surface\";\nimport { BlackboardNotificationBadge } from \"./parts/blackboard-notification-badge\";\nimport { BlackboardBackgroundEditor } from \"./parts/blackboard-background-editor\";\nimport { BlackboardPinnedRow } from \"./parts/blackboard-pinned-row\";\nimport { BlackboardNoteStream } from \"./parts/blackboard-note-stream\";\nimport { BlackboardComposer } from \"./parts/blackboard-composer\";\n\n/**\n * Batteries-included blackboard. Pure composition over `BlackboardRoot` + the flat\n * parts, gated by `show*` toggles — contains no logic the parts don't, so any\n * hand-assembled subset behaves identically. For a lighter build, compose the\n * parts directly and drop what you don't need (e.g. omit `BlackboardComposer`).\n */\nexport function Blackboard(props: BlackboardProps) {\n  const {\n    showComposer,\n    showNotificationBadge = true,\n    showBackgroundEditor,\n    showPinnedRow = true,\n    ...rootProps\n  } = props;\n\n  const composerVisible = showComposer ?? !!rootProps.onPostNote;\n  const bgEditorVisible = showBackgroundEditor ?? !!rootProps.editableBackground;\n\n  return (\n    <BlackboardRoot {...rootProps}>\n      <BlackboardSurface>\n        {showNotificationBadge ? <BlackboardNotificationBadge /> : null}\n        {bgEditorVisible ? <BlackboardBackgroundEditor /> : null}\n        {showPinnedRow ? <BlackboardPinnedRow /> : null}\n        <BlackboardNoteStream />\n        {composerVisible ? <BlackboardComposer /> : null}\n      </BlackboardSurface>\n    </BlackboardRoot>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/blackboard.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/blackboard-fonts.ts",
      "content": "// Self-hosted handwriting fonts (hybrid delivery — bundled defaults, overridable\n// via the `fonts` prop). Side-effect imports register the @font-face rules; the\n// families are referenced through `--bb-font-*` CSS vars injected by BlackboardRoot.\n//\n// These are the only design-mandate font exception in the library, scoped to note\n// text + the unread number. A consumer who fully replaces the set via `fonts` can\n// fork this module to a no-op to drop the bundled weight.\n//\n// The type import sits ABOVE the side-effect imports deliberately: the\n// `validate:meta-deps` import regex would otherwise span from the first `import`\n// down to this `from`, swallowing the @fontsource side-effect imports (phantom-npm).\nimport type { HandwritingFont } from \"./types\";\n\nimport \"@fontsource/kalam/300.css\";\nimport \"@fontsource/kalam/400.css\";\nimport \"@fontsource/kalam/700.css\";\nimport \"@fontsource-variable/caveat\";\nimport \"@fontsource/patrick-hand\";\nimport \"@fontsource/shadows-into-light\";\n\n/** CSS-var declarations BlackboardRoot injects on its wrapper (with cursive fallbacks). */\nexport const FONT_VAR_DECLARATIONS: Record<string, string> = {\n  \"--bb-font-kalam\": '\"Kalam\", cursive',\n  \"--bb-font-caveat\": '\"Caveat Variable\", \"Caveat\", cursive',\n  \"--bb-font-patrick\": '\"Patrick Hand\", cursive',\n  \"--bb-font-shadows\": '\"Shadows Into Light\", cursive',\n};\n\nexport const DEFAULT_FONTS: HandwritingFont[] = [\n  { key: \"kalam\", label: \"Kalam\", cssVar: \"--bb-font-kalam\", hasWeights: true },\n  { key: \"caveat\", label: \"Caveat\", cssVar: \"--bb-font-caveat\", hasWeights: false },\n  { key: \"patrick\", label: \"Patrick Hand\", cssVar: \"--bb-font-patrick\", hasWeights: false },\n  { key: \"shadows\", label: \"Shadows Into Light\", cssVar: \"--bb-font-shadows\", hasWeights: false },\n];\n",
      "type": "registry:component",
      "target": "components/blackboard/blackboard-fonts.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/index.ts",
      "content": "// Tier A — the batteries-included assembly\nexport { Blackboard } from \"./blackboard\";\n\n// Tier B — headless provider + context-connected parts (flat exports)\nexport { BlackboardRoot } from \"./parts/blackboard-root\";\nexport { BlackboardSurface } from \"./parts/blackboard-surface\";\nexport { BlackboardPinnedRow } from \"./parts/blackboard-pinned-row\";\nexport { BlackboardNoteStream } from \"./parts/blackboard-note-stream\";\nexport { BlackboardNoteItem } from \"./parts/blackboard-note-item\";\nexport { BlackboardComposer } from \"./parts/blackboard-composer\";\nexport { BlackboardNotificationBadge } from \"./parts/blackboard-notification-badge\";\nexport { BlackboardBackgroundEditor } from \"./parts/blackboard-background-editor\";\n\n// Tier C — standalone, context-free primitives\nexport { HandwrittenNote } from \"./parts/handwritten-note\";\nexport { NoteComposer } from \"./parts/note-composer\";\nexport { InkColorPicker } from \"./parts/ink-color-picker\";\nexport { ChalkWidthPicker } from \"./parts/chalk-width-picker\";\nexport { HandwritingFontPicker } from \"./parts/handwriting-font-picker\";\nexport { MentionPicker } from \"./parts/mention-picker\";\nexport { MentionText } from \"./parts/mention-text\";\nexport { UnreadCount } from \"./parts/unread-count\";\nexport { BoardBackground } from \"./parts/board-background\";\n\n// context hook\nexport { useBlackboard } from \"./hooks/use-blackboard\";\n\n// constants\nexport { DEFAULT_PALETTE, DEFAULT_WIDTHS } from \"./lib/palette\";\nexport { DEFAULT_FONTS } from \"./blackboard-fonts\";\n\n// types\nexport type * from \"./types\";\n\n// part prop types (for à-la-carte consumers)\nexport type { HandwrittenNoteProps } from \"./parts/handwritten-note\";\nexport type { NoteComposerProps } from \"./parts/note-composer\";\nexport type { InkColorPickerProps } from \"./parts/ink-color-picker\";\nexport type { ChalkWidthPickerProps } from \"./parts/chalk-width-picker\";\nexport type { HandwritingFontPickerProps } from \"./parts/handwriting-font-picker\";\nexport type { MentionPickerProps } from \"./parts/mention-picker\";\nexport type { MentionTextProps } from \"./parts/mention-text\";\nexport type { UnreadCountProps } from \"./parts/unread-count\";\nexport type { BoardBackgroundProps } from \"./parts/board-background\";\nexport type { BlackboardSurfaceProps } from \"./parts/blackboard-surface\";\n",
      "type": "registry:component",
      "target": "components/blackboard/index.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/types.ts",
      "content": "import type { CSSProperties, ReactNode, Ref } from \"react\";\n\n// ── identity ───────────────────────────────────────────────\n\nexport interface BlackboardAuthor {\n  id: string;\n  name: string;\n  avatarUrl?: string;\n  /** Optional per-author default ink (palette key or raw CSS color). */\n  inkColor?: string;\n}\n\n/** Mention-able team roster (a superset of authors). Drives the @-picker. */\nexport interface BlackboardMember {\n  id: string;\n  name: string;\n  avatarUrl?: string;\n}\n\n// ── note styling (the three writing controls) ──────────────\n\n/** Chalk thickness. Maps to real font-weight where the font has weights, else a faux text-stroke. */\nexport type NoteWidth = \"thin\" | \"regular\" | \"bold\";\n\nexport interface NoteStyle {\n  /** Palette key (e.g. \"lime\") or a raw CSS color when `allowFreeColor`. */\n  color: string;\n  width: NoteWidth;\n  /** A `HandwritingFont.key`. */\n  font: string;\n}\n\nexport interface HandwritingFont {\n  /** Stable id stored on the note (\"kalam\", \"caveat\", …). */\n  key: string;\n  /** Picker label (\"Kalam\"). */\n  label: string;\n  /** The CSS var the family is exposed through (\"--bb-font-kalam\"). */\n  cssVar: string;\n  /** True ⇒ real font-weight for width; false ⇒ faux text-stroke. */\n  hasWeights?: boolean;\n}\n\n/** An ink swatch offered in the composer. */\nexport interface InkColor {\n  /** Stable key stored on the note (\"chalk\", \"lime\", …). */\n  key: string;\n  label: string;\n  /** Resolved CSS color (an oklch / hex / var()). */\n  value: string;\n}\n\n// ── mentions ───────────────────────────────────────────────\n\nexport interface Mention {\n  memberId: string;\n  /** The \"@name\" exactly as written. */\n  display: string;\n  /** Char offset into `note.text`. */\n  start: number;\n  /** Length of the `display` substring. */\n  length: number;\n}\n\n// ── the note ───────────────────────────────────────────────\n\nexport interface BlackboardNote {\n  id: string;\n  text: string;\n  author: BlackboardAuthor;\n  /** ISO 8601. */\n  createdAt: string;\n  updatedAt?: string;\n  style: NoteStyle;\n  /**\n   * Uncontrolled pin flag. Ignored when the `pinnedNoteIds` prop is supplied\n   * (that prop is the controlled source of truth).\n   */\n  pinned?: boolean;\n  mentions?: Mention[];\n  meta?: Record<string, unknown>;\n  /** @internal Set on optimistic notes awaiting a server id; cleared on reconcile. */\n  pending?: boolean;\n  /** @internal Set when an optimistic post failed; surfaces a retry affordance. */\n  failed?: boolean;\n}\n\n// ── board background ───────────────────────────────────────\n\nexport type BoardBackground =\n  | { kind: \"color\"; value: string }\n  | { kind: \"image\"; url: string; overlay?: number };\n\n// ── note draft (composer ↔ callbacks) ──────────────────────\n\nexport interface NoteDraft {\n  text: string;\n  style: NoteStyle;\n  mentions: Mention[];\n}\n\n// ── labels (i18n surface) ──────────────────────────────────\n\nexport interface BlackboardLabels {\n  composerPlaceholder: string;\n  post: string;\n  loadOlder: string;\n  loadingOlder: string;\n  pinnedHeading: string;\n  empty: string;\n  unreadAria: (n: number) => string;\n  mentionYou: string;\n  colorLabel: string;\n  widthLabel: string;\n  fontLabel: string;\n  pin: string;\n  unpin: string;\n  delete: string;\n  retry: string;\n  doubleClickHint: string;\n  closeComposer: string;\n  authoredBy: (name: string) => string;\n  backgroundLabel: string;\n  backgroundColor: string;\n  backgroundImage: string;\n  backgroundImageUrl: string;\n}\n\n// ── imperative handle ──────────────────────────────────────\n\nexport interface BlackboardHandle {\n  scrollToLatest(): void;\n  /** Push an inbound real-time note (dedup-safe against ids + pending optimistic notes). */\n  appendNote(note: BlackboardNote): void;\n  focusComposer(): void;\n  markAllSeen(): void;\n}\n\n// ── root / assembly props ──────────────────────────────────\n\n/**\n * Props shared by the headless `BlackboardRoot` provider and the `Blackboard`\n * assembly. The assembly adds the `show*` chrome toggles.\n */\nexport interface BlackboardRootProps {\n  /** Controlled stream, oldest → newest. */\n  notes: BlackboardNote[];\n  currentUser: BlackboardAuthor;\n  /** Mention roster; empty/undefined ⇒ no @-picker. */\n  members?: BlackboardMember[];\n  /** Default true. false ⇒ composer disabled (with `renderWriteDenied`). */\n  canWrite?: boolean;\n\n  // lazy load older (scroll-up)\n  onLoadOlder?(beforeNoteId: string | null, limit: number): Promise<BlackboardNote[]>;\n  hasMoreOlder?: boolean;\n  /** Default 10. */\n  loadOlderPageSize?: number;\n\n  // write / persist (auto-save = debounced; NO network here)\n  onPostNote?(draft: NoteDraft): void | Promise<BlackboardNote>;\n  onUpdateNote?(id: string, patch: Partial<NoteDraft>): void;\n  onDeleteNote?(id: string): void;\n  onDraftChange?(draft: NoteDraft): void;\n  /** Debounce for `onDraftChange`. Default 600. */\n  autoSaveDelayMs?: number;\n\n  // pin — `pinnedNoteIds` (controlled) wins; else `note.pinned` (uncontrolled).\n  pinnedNoteIds?: string[];\n  onPinNote?(id: string): void;\n  onUnpinNote?(id: string): void;\n\n  // mentions — fires AFTER onPostNote resolves, with the reconciled note id.\n  onMention?(noteId: string, memberIds: string[]): void;\n\n  // unread marker\n  unreadCount?: number;\n  lastSeenNoteId?: string | null;\n  onSeen?(latestNoteId: string): void;\n\n  // board theming\n  background?: BoardBackground;\n  defaultBackground?: BoardBackground;\n  onBackgroundChange?(bg: BoardBackground): void;\n  editableBackground?: boolean;\n\n  // writing palette constraints\n  palette?: InkColor[];\n  fonts?: HandwritingFont[];\n  widths?: NoteWidth[];\n  allowFreeColor?: boolean;\n  defaultStyle?: Partial<NoteStyle>;\n\n  // behaviour\n  /** Default false (newest at bottom). */\n  newestFirst?: boolean;\n  /** Default true. */\n  showAuthorOnHover?: boolean;\n  /**\n   * How the composer is revealed. `\"double-click\"` (default) keeps the board clean\n   * and opens the composer when the user double-clicks the surface; `\"always\"` keeps\n   * it docked at the bottom. Ignored when there's no composer (no `onPostNote`).\n   */\n  composerMode?: \"always\" | \"double-click\";\n\n  // escape hatches\n  renderWriteDenied?(): ReactNode;\n  renderEmpty?(): ReactNode;\n  labels?: Partial<BlackboardLabels>;\n\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  ref?: Ref<BlackboardHandle>;\n}\n\nexport interface BlackboardProps extends Omit<BlackboardRootProps, \"children\"> {\n  // chrome toggles (compound assembly)\n  /** Default = `!!onPostNote`. */\n  showComposer?: boolean;\n  /** Default true. */\n  showNotificationBadge?: boolean;\n  /** Default = `editableBackground`. */\n  showBackgroundEditor?: boolean;\n  /** Default true. */\n  showPinnedRow?: boolean;\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/types.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-autosave.ts",
      "content": "import { useEffect, useRef } from \"react\";\nimport type { NoteDraft } from \"../types\";\n\n/**\n * Debounced draft autosave. Fires `onDraftChange` `delayMs` after the draft stops\n * changing — \"auto-save\" = no data loss + no mandatory Save click, NOT a per-keystroke\n * post. Skips the very first run (mount) so an empty initial draft doesn't fire.\n */\nexport function useAutosave(\n  draft: NoteDraft,\n  onDraftChange: ((draft: NoteDraft) => void) | undefined,\n  delayMs: number,\n): void {\n  const cbRef = useRef(onDraftChange);\n  useEffect(() => {\n    cbRef.current = onDraftChange;\n  });\n\n  const mountedRef = useRef(false);\n  useEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    if (!cbRef.current) return;\n    const id = setTimeout(() => cbRef.current?.(draft), delayMs);\n    return () => clearTimeout(id);\n  }, [draft, delayMs]);\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-autosave.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-blackboard.ts",
      "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { RefObject, ReactNode } from \"react\";\nimport type {\n  BlackboardLabels,\n  BlackboardAuthor,\n  BlackboardMember,\n  BlackboardNote,\n  BoardBackground,\n  HandwritingFont,\n  InkColor,\n  NoteDraft,\n  NoteStyle,\n  NoteWidth,\n} from \"../types\";\n\n/** The value shared by every context-connected part. Built by `useBlackboardController`. */\nexport interface BlackboardContextValue {\n  labels: Required<BlackboardLabels>;\n  currentUser: BlackboardAuthor;\n  members: BlackboardMember[];\n  canWrite: boolean;\n\n  // display\n  streamNotes: BlackboardNote[]; // chronological (oldest→newest); pinned lifted out\n  pinnedNotes: BlackboardNote[];\n  newestFirst: boolean;\n  isPinned: (id: string) => boolean;\n  showAuthorOnHover: boolean;\n  mentionsCurrentUser: (note: BlackboardNote) => boolean;\n\n  // palette / fonts\n  palette: InkColor[];\n  fonts: HandwritingFont[];\n  widths: NoteWidth[];\n  allowFreeColor: boolean;\n\n  // composer / draft\n  composerRef: RefObject<HTMLTextAreaElement | null>;\n  draft: NoteDraft;\n  setDraftText: (text: string) => void;\n  setDraftStyle: (patch: Partial<NoteStyle>) => void;\n  post: () => void;\n  posting: boolean;\n  composerMode: \"always\" | \"double-click\";\n  composerOpen: boolean;\n  openComposer: () => void;\n  closeComposer: () => void;\n\n  // lazy load\n  scrollRef: RefObject<HTMLDivElement | null>;\n  sentinelRef: RefObject<HTMLDivElement | null>;\n  hasMoreOlder: boolean;\n  loadingOlder: boolean;\n  loadOlderEnabled: boolean;\n  loadOlder: () => Promise<void>;\n  onReachedBottom: () => void;\n  scrollToLatest: () => void;\n\n  // unread\n  unreadCount: number;\n  markAllSeen: () => void;\n\n  // pin / delete / retry\n  canPin: boolean;\n  togglePin: (note: BlackboardNote) => void;\n  canDelete: boolean;\n  deleteNote: (note: BlackboardNote) => void;\n  retryPost: (id: string) => void;\n\n  // background\n  background: BoardBackground;\n  setBackground: (bg: BoardBackground) => void;\n  editableBackground: boolean;\n\n  renderWriteDenied?: () => ReactNode;\n  renderEmpty?: () => ReactNode;\n}\n\nconst BlackboardContext = createContext<BlackboardContextValue | null>(null);\n\nexport { BlackboardContext };\n\n/** Read the blackboard context. Throws if used outside `<BlackboardRoot>`. */\nexport function useBlackboard(): BlackboardContextValue {\n  const ctx = useContext(BlackboardContext);\n  if (ctx === null) {\n    throw new Error(\"useBlackboard must be used within <BlackboardRoot> (or <Blackboard>).\");\n  }\n  return ctx;\n}\n\nexport const DEFAULT_BLACKBOARD_LABELS: Required<BlackboardLabels> = {\n  composerPlaceholder: \"Write a note…\",\n  post: \"Post\",\n  loadOlder: \"Load older notes\",\n  loadingOlder: \"Loading…\",\n  pinnedHeading: \"Pinned\",\n  empty: \"Nothing on the board yet\",\n  unreadAria: (n) => `${n} unread ${n === 1 ? \"note\" : \"notes\"}`,\n  mentionYou: \"@you\",\n  colorLabel: \"Ink color\",\n  widthLabel: \"Chalk width\",\n  fontLabel: \"Handwriting\",\n  pin: \"Pin\",\n  unpin: \"Unpin\",\n  delete: \"Delete\",\n  retry: \"Retry\",\n  doubleClickHint: \"Double-click to write a note\",\n  closeComposer: \"Close composer\",\n  authoredBy: (name) => name,\n  backgroundLabel: \"Board background\",\n  backgroundColor: \"Color\",\n  backgroundImage: \"Image\",\n  backgroundImageUrl: \"Image URL\",\n};\n\nexport const DEFAULT_BACKGROUND: BoardBackground = {\n  kind: \"color\",\n  value: \"oklch(0.18 0.04 250)\",\n};\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-blackboard.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-blackboard-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport type {\n  BlackboardHandle,\n  BlackboardNote,\n  BlackboardRootProps,\n  BoardBackground,\n  NoteDraft,\n  NoteStyle,\n} from \"../types\";\nimport {\n  DEFAULT_NOTE_STYLE,\n  DEFAULT_PALETTE,\n  DEFAULT_WIDTHS,\n} from \"../lib/palette\";\nimport { DEFAULT_FONTS } from \"../blackboard-fonts\";\nimport { dedupeMemberIds, extractMentions } from \"../lib/mentions\";\nimport { deriveUnread, latestNoteId } from \"../lib/unread\";\nimport { useControllableState } from \"./use-controllable-state\";\nimport { useAutosave } from \"./use-autosave\";\nimport { useLazyOlder } from \"./use-lazy-older\";\nimport {\n  BlackboardContext,\n  DEFAULT_BACKGROUND,\n  DEFAULT_BLACKBOARD_LABELS,\n  type BlackboardContextValue,\n} from \"./use-blackboard\";\n\nfunction genTempId(): string {\n  // Event-handler-only (post/retry) — never called during SSR render.\n  const rnd =\n    typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n      ? crypto.randomUUID()\n      : `${performance.now()}`;\n  return `bb-temp-${rnd}`;\n}\n\nfunction nowIso(): string {\n  return new Date().toISOString();\n}\n\n// Drops extras the controlled `notes` prop has absorbed (same id). Pure — safe\n// under StrictMode double-invoked updaters. Returns `prev` identity when nothing\n// is absorbed so callers don't trigger spurious renders. Called opportunistically\n// from the `setExtras` writers (post / appendNote / loadOlder) instead of a prune\n// effect, which would be a set-state-in-effect cascade (v0.1.0 review F-03).\nfunction pruneAbsorbed(prev: BlackboardNote[], notes: BlackboardNote[]): BlackboardNote[] {\n  if (prev.length === 0) return prev;\n  const propIds = new Set(notes.map((n) => n.id));\n  const next = prev.filter((e) => !propIds.has(e.id));\n  return next.length === prev.length ? prev : next;\n}\n\n/**\n * The headless controller — builds the full `BlackboardContextValue` from\n * `BlackboardRootProps`. Owns optimistic posting + reconcile, lazy-load cursor,\n * draft + autosave, pin (controlled or uncontrolled), unread derivation, board\n * theming, and the imperative handle.\n */\nexport function useBlackboardController(props: BlackboardRootProps): BlackboardContextValue {\n  const {\n    notes,\n    currentUser,\n    members = [],\n    canWrite = true,\n    onLoadOlder,\n    hasMoreOlder = false,\n    loadOlderPageSize = 10,\n    onPostNote,\n    onDeleteNote,\n    onDraftChange,\n    autoSaveDelayMs = 600,\n    pinnedNoteIds,\n    onPinNote,\n    onUnpinNote,\n    onMention,\n    unreadCount,\n    lastSeenNoteId,\n    onSeen,\n    background,\n    defaultBackground,\n    onBackgroundChange,\n    editableBackground = false,\n    palette = DEFAULT_PALETTE,\n    fonts = DEFAULT_FONTS,\n    widths = DEFAULT_WIDTHS,\n    allowFreeColor = false,\n    defaultStyle,\n    newestFirst = false,\n    showAuthorOnHover = true,\n    composerMode = \"double-click\",\n    renderWriteDenied,\n    renderEmpty,\n    labels: labelOverrides,\n    ref,\n  } = props;\n\n  const labels = useMemo(\n    () => ({ ...DEFAULT_BLACKBOARD_LABELS, ...labelOverrides }),\n    [labelOverrides],\n  );\n\n  // refs\n  const composerRef = useRef<HTMLTextAreaElement | null>(null);\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  const sentinelRef = useRef<HTMLDivElement | null>(null);\n\n  // ── optimistic / appended notes (local) ──────────────────\n  const [extras, setExtras] = useState<BlackboardNote[]>([]);\n  const [posting, setPosting] = useState(false);\n\n  // ── composer reveal ──────────────────────────────────────\n  const [composerOpenState, setComposerOpenState] = useState(false);\n  const composerOpen = composerMode === \"always\" ? true : composerOpenState;\n  const openComposer = useCallback(() => {\n    setComposerOpenState(true);\n    requestAnimationFrame(() => composerRef.current?.focus());\n  }, []);\n  const closeComposer = useCallback(() => setComposerOpenState(false), []);\n\n  // Display = controlled `notes` ∪ local extras (optimistic posts, real-time\n  // appends, loaded-older pages), deduped by id with `notes` winning. Extras whose\n  // id is now in `notes` are filtered out here; the buffer itself is pruned\n  // opportunistically via `pruneAbsorbed` in the `setExtras` writers (F-03).\n  const merged = useMemo(() => {\n    const propIds = new Set(notes.map((n) => n.id));\n    const extraOnly = extras.filter((e) => !propIds.has(e.id));\n    const all = [...notes, ...extraOnly];\n    all.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));\n    return all;\n  }, [notes, extras]);\n\n  // ── pin (controlled `pinnedNoteIds` XOR uncontrolled `note.pinned`) ──\n  const pinControlled = pinnedNoteIds !== undefined;\n  const [localPinned, setLocalPinned] = useState<Set<string>>(\n    () => new Set(notes.filter((n) => n.pinned).map((n) => n.id)),\n  );\n  const pinnedSet = useMemo(\n    () => (pinControlled ? new Set(pinnedNoteIds) : localPinned),\n    [pinControlled, pinnedNoteIds, localPinned],\n  );\n  const isPinned = useCallback((id: string) => pinnedSet.has(id), [pinnedSet]);\n\n  const streamNotes = useMemo(() => merged.filter((n) => !pinnedSet.has(n.id)), [merged, pinnedSet]);\n  const pinnedNotes = useMemo(() => merged.filter((n) => pinnedSet.has(n.id)), [merged, pinnedSet]);\n\n  const canPin = !!onPinNote;\n  const togglePin = useCallback(\n    (note: BlackboardNote) => {\n      const willPin = !pinnedSet.has(note.id);\n      if (willPin) onPinNote?.(note.id);\n      else onUnpinNote?.(note.id);\n      if (!pinControlled) {\n        setLocalPinned((prev) => {\n          const next = new Set(prev);\n          if (willPin) next.add(note.id);\n          else next.delete(note.id);\n          return next;\n        });\n      }\n    },\n    [pinnedSet, onPinNote, onUnpinNote, pinControlled],\n  );\n\n  // ── background (controlled / uncontrolled) ───────────────\n  const [bg, setBg] = useControllableState<BoardBackground>({\n    value: background,\n    defaultValue: defaultBackground ?? DEFAULT_BACKGROUND,\n    onChange: onBackgroundChange,\n    componentName: \"Blackboard\",\n    valuePropName: \"background\",\n  });\n\n  // ── draft + autosave ─────────────────────────────────────\n  const [draft, setDraft] = useState<NoteDraft>(() => ({\n    text: \"\",\n    style: { ...DEFAULT_NOTE_STYLE, ...defaultStyle },\n    mentions: [],\n  }));\n  const setDraftText = useCallback(\n    (text: string) => setDraft((d) => ({ ...d, text })),\n    [],\n  );\n  const setDraftStyle = useCallback(\n    (patch: Partial<NoteStyle>) => setDraft((d) => ({ ...d, style: { ...d.style, ...patch } })),\n    [],\n  );\n  useAutosave(draft, onDraftChange, autoSaveDelayMs);\n\n  // ── scroll helpers ───────────────────────────────────────\n  const scrollToLatest = useCallback(() => {\n    requestAnimationFrame(() => {\n      const el = scrollRef.current;\n      if (!el) return;\n      el.scrollTop = newestFirst ? 0 : el.scrollHeight;\n    });\n  }, [newestFirst]);\n\n  // ── unread ───────────────────────────────────────────────\n  const seenControlled = lastSeenNoteId !== undefined;\n  const [localSeenId, setLocalSeenId] = useState<string | null>(null);\n  const effectiveSeen = seenControlled ? lastSeenNoteId ?? null : localSeenId;\n  const derivedUnread = deriveUnread(merged, effectiveSeen);\n  const effectiveUnread = unreadCount ?? derivedUnread;\n\n  const markAllSeen = useCallback(() => {\n    const latest = latestNoteId(merged);\n    if (latest) onSeen?.(latest);\n    if (!seenControlled) setLocalSeenId(latest);\n  }, [merged, onSeen, seenControlled]);\n\n  const onReachedBottom = useCallback(() => {\n    if (!newestFirst) markAllSeen();\n  }, [newestFirst, markAllSeen]);\n\n  // ── posting (optimistic + reconcile) ─────────────────────\n  const submit = useCallback(\n    (optimistic: BlackboardNote) => {\n      const mentions = optimistic.mentions ?? [];\n      const memberIds = dedupeMemberIds(mentions);\n      const result = onPostNote?.({ text: optimistic.text, style: optimistic.style, mentions });\n      if (result instanceof Promise) {\n        setPosting(true);\n        result\n          .then((real) => {\n            setExtras((prev) => prev.map((e) => (e.id === optimistic.id ? { ...real } : e)));\n            if (memberIds.length) onMention?.(real.id, memberIds);\n          })\n          .catch(() => {\n            setExtras((prev) =>\n              prev.map((e) =>\n                e.id === optimistic.id ? { ...e, pending: false, failed: true } : e,\n              ),\n            );\n          })\n          .finally(() => setPosting(false));\n      } else {\n        // sync / void: drop the pending flag; fire mention with the optimistic id\n        setExtras((prev) =>\n          prev.map((e) => (e.id === optimistic.id ? { ...e, pending: false } : e)),\n        );\n        if (memberIds.length) onMention?.(optimistic.id, memberIds);\n      }\n    },\n    [onPostNote, onMention],\n  );\n\n  const post = useCallback(() => {\n    const text = draft.text.trim();\n    if (!canWrite || !onPostNote || !text) return;\n    const mentions = extractMentions(text, members);\n    const optimistic: BlackboardNote = {\n      id: genTempId(),\n      text,\n      author: currentUser,\n      createdAt: nowIso(),\n      style: draft.style,\n      mentions,\n      pending: true,\n    };\n    setExtras((prev) => [...pruneAbsorbed(prev, notes), optimistic]);\n    setDraft((d) => ({ ...d, text: \"\", mentions: [] }));\n    submit(optimistic);\n    scrollToLatest();\n  }, [draft.text, draft.style, canWrite, onPostNote, members, currentUser, notes, submit, scrollToLatest]);\n\n  const retryPost = useCallback(\n    (id: string) => {\n      // v0.1.1 (review §6 medium) — read the note BEFORE dispatch and submit\n      // OUTSIDE the updater. Calling `submit` (→ consumer `onPostNote`\n      // network call) inside the setExtras updater was impure: StrictMode's\n      // double-invoked updater posted the note to the server twice.\n      const note = extras.find((e) => e.id === id);\n      if (!note) return;\n      setExtras((prev) =>\n        prev.map((e) => (e.id === id ? { ...e, pending: true, failed: false } : e)),\n      );\n      submit({ ...note, pending: true, failed: false });\n    },\n    [extras, submit],\n  );\n\n  // ── delete ───────────────────────────────────────────────\n  const canDelete = !!onDeleteNote;\n  const deleteNote = useCallback(\n    (note: BlackboardNote) => {\n      if (note.failed) {\n        // dismiss a failed optimistic note locally\n        setExtras((prev) => prev.filter((e) => e.id !== note.id));\n        return;\n      }\n      onDeleteNote?.(note.id);\n      setExtras((prev) => prev.filter((e) => e.id !== note.id));\n    },\n    [onDeleteNote],\n  );\n\n  // ── lazy load older ──────────────────────────────────────\n  const loadOlder = useCallback(async () => {\n    if (!onLoadOlder) return;\n    const oldest = merged.find((n) => !n.pending && !n.failed);\n    const older = await onLoadOlder(oldest ? oldest.id : null, loadOlderPageSize);\n    if (older.length === 0) return;\n    // Prepend any that aren't already present (consumer owns `notes`, but we\n    // surface them immediately via extras until the host absorbs them).\n    setExtras((prev) => {\n      const base = pruneAbsorbed(prev, notes);\n      const known = new Set([...notes.map((n) => n.id), ...base.map((e) => e.id)]);\n      const fresh = older.filter((o) => !known.has(o.id));\n      return fresh.length ? [...fresh, ...base] : base;\n    });\n  }, [onLoadOlder, merged, loadOlderPageSize, notes]);\n\n  const { loadingOlder } = useLazyOlder({\n    scrollRef,\n    sentinelRef,\n    load: loadOlder,\n    hasMore: hasMoreOlder,\n    enabled: !!onLoadOlder,\n    topId: streamNotes.length > 0 ? streamNotes[0].id : null,\n    anchor: !newestFirst,\n  });\n\n  // ── mention emphasis ─────────────────────────────────────\n  const mentionsCurrentUser = useCallback(\n    (note: BlackboardNote) => !!note.mentions?.some((m) => m.memberId === currentUser.id),\n    [currentUser.id],\n  );\n\n  // ── imperative handle ────────────────────────────────────\n  useImperativeHandle(\n    ref,\n    (): BlackboardHandle => ({\n      scrollToLatest,\n      appendNote: (note) => {\n        setExtras((prev) => {\n          if (prev.some((e) => e.id === note.id)) return prev;\n          // Prune absorbed entries, then cap so a long-lived real-time stream\n          // can't grow unbounded.\n          const next = [...pruneAbsorbed(prev, notes), note];\n          return next.length > 300 ? next.slice(next.length - 300) : next;\n        });\n        scrollToLatest();\n      },\n      focusComposer: () => composerRef.current?.focus(),\n      markAllSeen,\n    }),\n    [scrollToLatest, markAllSeen, notes],\n  );\n\n  return {\n    labels,\n    currentUser,\n    members,\n    canWrite,\n    streamNotes,\n    pinnedNotes,\n    newestFirst,\n    isPinned,\n    showAuthorOnHover,\n    mentionsCurrentUser,\n    palette,\n    fonts,\n    widths,\n    allowFreeColor,\n    composerRef,\n    draft,\n    setDraftText,\n    setDraftStyle,\n    post,\n    posting,\n    composerMode,\n    composerOpen,\n    openComposer,\n    closeComposer,\n    scrollRef,\n    sentinelRef,\n    hasMoreOlder,\n    loadingOlder,\n    loadOlderEnabled: !!onLoadOlder,\n    loadOlder,\n    onReachedBottom,\n    scrollToLatest,\n    unreadCount: effectiveUnread,\n    markAllSeen,\n    canPin,\n    togglePin,\n    canDelete,\n    deleteNote,\n    retryPost,\n    background: bg,\n    setBackground: setBg,\n    editableBackground,\n    renderWriteDenied,\n    renderEmpty,\n  } satisfies BlackboardContextValue;\n}\n\nexport { BlackboardContext };\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-blackboard-state.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-controllable-state.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface UseControllableStateOpts<T> {\n  /** Controlled value. When provided, state is fully controlled by parent. */\n  value?: T;\n  /** Initial value for uncontrolled mode. */\n  defaultValue: T;\n  /** Fires on every state change (both modes). */\n  onChange?: (next: T) => void;\n  /** Component name used in dev warnings. */\n  componentName: string;\n  /** Prop name used in dev warnings (e.g., `\"background\"`). */\n  valuePropName: string;\n}\n\n/**\n * Controlled+uncontrolled state helper. Sealed copy of the proven generic used\n * across the library (account-switcher, code-block, media-library) —\n * registry portability bans a shared cross-component module, so each procomp\n * vendors its own. Locks the mode at first render and dev-warns on mode-flip.\n */\nexport function useControllableState<T>({\n  value,\n  defaultValue,\n  onChange,\n  componentName,\n  valuePropName,\n}: UseControllableStateOpts<T>): readonly [T, (next: T) => void] {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const isControlled = value !== undefined;\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  });\n\n  const wasControlledRef = useRef(isControlled);\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (wasControlledRef.current !== isControlled) {\n      console.warn(\n        `[${componentName}] \\`${valuePropName}\\` switched from ${\n          wasControlledRef.current ? \"controlled\" : \"uncontrolled\"\n        } to ${\n          isControlled ? \"controlled\" : \"uncontrolled\"\n        } mode. Components should not switch modes mid-life; pick one at mount.`,\n      );\n      wasControlledRef.current = isControlled;\n    }\n  }, [isControlled, componentName, valuePropName]);\n\n  const current = isControlled ? (value as T) : internal;\n\n  const set = useCallback(\n    (next: T) => {\n      if (!isControlled) setInternal(next);\n      onChangeRef.current?.(next);\n    },\n    [isControlled],\n  );\n\n  return [current, set] as const;\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-controllable-state.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-lazy-older.ts",
      "content": "import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { RefObject } from \"react\";\n\ninterface UseLazyOlderOpts {\n  scrollRef: RefObject<HTMLElement | null>;\n  sentinelRef: RefObject<HTMLElement | null>;\n  /** Fetch + prepend the next older page. Resolves once state has been updated. */\n  load: () => Promise<void>;\n  hasMore: boolean;\n  enabled: boolean;\n  /** The id of the current top note — changes when an older page prepends. */\n  topId: string | null;\n  /** When true (newest-at-bottom), preserve scroll position across a top-prepend. */\n  anchor: boolean;\n}\n\n/**\n * Drives scroll-up lazy loading: an IntersectionObserver on a top sentinel triggers\n * `load()` when it scrolls into view (guarded against re-entry), and — in anchor mode —\n * preserves the viewport so prepended notes don't make the content jump.\n */\nexport function useLazyOlder({\n  scrollRef,\n  sentinelRef,\n  load,\n  hasMore,\n  enabled,\n  topId,\n  anchor,\n}: UseLazyOlderOpts): { loadingOlder: boolean } {\n  const [loadingOlder, setLoadingOlder] = useState(false);\n  const loadingRef = useRef(false);\n  const prevHeightRef = useRef<number | null>(null);\n\n  const trigger = useCallback(async () => {\n    if (loadingRef.current || !hasMore || !enabled) return;\n    loadingRef.current = true;\n    setLoadingOlder(true);\n    if (anchor && scrollRef.current) prevHeightRef.current = scrollRef.current.scrollHeight;\n    try {\n      await load();\n    } finally {\n      loadingRef.current = false;\n      setLoadingOlder(false);\n    }\n  }, [hasMore, enabled, anchor, load, scrollRef]);\n\n  // Restore scroll position after a top-prepend (anchor mode only).\n  useLayoutEffect(() => {\n    const el = scrollRef.current;\n    if (!anchor || el == null || prevHeightRef.current == null) return;\n    const delta = el.scrollHeight - prevHeightRef.current;\n    if (delta > 0) el.scrollTop += delta;\n    prevHeightRef.current = null;\n    // Keyed on topId: fires exactly when the leading note changes (a prepend).\n  }, [topId, anchor, scrollRef]);\n\n  // Observe the sentinel within the scroll container.\n  useEffect(() => {\n    const root = scrollRef.current;\n    const sentinel = sentinelRef.current;\n    if (!root || !sentinel || !enabled || !hasMore) return;\n    const io = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.isIntersecting)) void trigger();\n      },\n      { root, rootMargin: \"120px 0px 0px 0px\", threshold: 0 },\n    );\n    io.observe(sentinel);\n    return () => io.disconnect();\n  }, [scrollRef, sentinelRef, enabled, hasMore, trigger]);\n\n  return { loadingOlder };\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-lazy-older.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/hooks/use-mentions.ts",
      "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, RefObject } from \"react\";\nimport type { BlackboardMember } from \"../types\";\nimport {\n  detectActiveMention,\n  filterMembers,\n  insertMention,\n  type ActiveMention,\n} from \"../lib/mentions\";\n\ninterface UseMentionsOpts {\n  textareaRef: RefObject<HTMLTextAreaElement | null>;\n  text: string;\n  members: BlackboardMember[];\n  setText: (text: string) => void;\n}\n\ninterface UseMentionsApi {\n  active: ActiveMention | null;\n  candidates: BlackboardMember[];\n  highlight: number;\n  setHighlight: (i: number) => void;\n  /** Recompute the active mention from the caret (call on input / click / keyup). */\n  refresh: () => void;\n  choose: (member: BlackboardMember) => void;\n  close: () => void;\n  /** Returns true if the keydown was consumed (open + nav keys). */\n  onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => boolean;\n}\n\n/**\n * Wires `@`-mention detection to a controlled textarea. The picker is anchored to\n * the composer (NOT a Popover primitive / PopoverAnchor — Base UI lacks it, per\n * F-cross-13). Keyboard: ↑/↓ to move, Enter/Tab to choose, Esc to dismiss.\n */\nexport function useMentions({\n  textareaRef,\n  text,\n  members,\n  setText,\n}: UseMentionsOpts): UseMentionsApi {\n  const [active, setActive] = useState<ActiveMention | null>(null);\n  const [highlight, setHighlight] = useState(0);\n  const pendingCaret = useRef<number | null>(null);\n\n  const enabled = members.length > 0;\n\n  const candidates = useMemo(\n    () => (active && enabled ? filterMembers(members, active.query) : []),\n    [active, enabled, members],\n  );\n\n  const close = useCallback(() => setActive(null), []);\n\n  const refresh = useCallback(() => {\n    if (!enabled) return;\n    const el = textareaRef.current;\n    if (!el) return;\n    const next = detectActiveMention(el.value, el.selectionStart ?? el.value.length);\n    setActive(next);\n    setHighlight(0);\n  }, [enabled, textareaRef]);\n\n  const choose = useCallback(\n    (member: BlackboardMember) => {\n      if (!active) return;\n      const { text: nextText, caret } = insertMention(text, active, member);\n      pendingCaret.current = caret;\n      setText(nextText);\n      setActive(null);\n    },\n    [active, text, setText],\n  );\n\n  // Restore the caret after a controlled-value insert lands in the DOM.\n  useEffect(() => {\n    if (pendingCaret.current == null) return;\n    const el = textareaRef.current;\n    if (el) {\n      el.focus();\n      el.setSelectionRange(pendingCaret.current, pendingCaret.current);\n    }\n    pendingCaret.current = null;\n  }, [text, textareaRef]);\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>): boolean => {\n      if (!active || candidates.length === 0) return false;\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        setHighlight((h) => (h + 1) % candidates.length);\n        return true;\n      }\n      if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setHighlight((h) => (h - 1 + candidates.length) % candidates.length);\n        return true;\n      }\n      if (e.key === \"Enter\" || e.key === \"Tab\") {\n        e.preventDefault();\n        choose(candidates[highlight]);\n        return true;\n      }\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        close();\n        return true;\n      }\n      return false;\n    },\n    [active, candidates, highlight, choose, close],\n  );\n\n  return { active, candidates, highlight, setHighlight, refresh, choose, close, onKeyDown };\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/hooks/use-mentions.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/lib/mentions.ts",
      "content": "import type { BlackboardMember, Mention } from \"../types\";\n\n/** A `@` token currently being typed: its start offset + the query after the `@`. */\nexport interface ActiveMention {\n  /** Index of the `@`. */\n  at: number;\n  /** Text between `@` and the caret. */\n  query: string;\n}\n\nconst MENTION_BOUNDARY = /[\\s.,!?;:()[\\]{}\"']/;\n\n/**\n * Given the full text and the caret position, detect whether the caret sits inside\n * an in-progress `@mention` token. Returns null if not (e.g. there's whitespace\n * between the `@` and the caret, or no `@` precedes the caret on this run).\n */\nexport function detectActiveMention(text: string, caret: number): ActiveMention | null {\n  // Walk backwards from the caret to find a preceding \"@\" with no boundary char in between.\n  for (let i = caret - 1; i >= 0; i--) {\n    const ch = text[i];\n    if (ch === \"@\") {\n      // \"@\" must start the string or follow a boundary char (avoid emails like a@b).\n      const prev = text[i - 1];\n      if (i === 0 || MENTION_BOUNDARY.test(prev)) {\n        return { at: i, query: text.slice(i + 1, caret) };\n      }\n      return null;\n    }\n    if (MENTION_BOUNDARY.test(ch)) return null;\n  }\n  return null;\n}\n\n/** Filter members by a (case-insensitive) name query. */\nexport function filterMembers(members: BlackboardMember[], query: string): BlackboardMember[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return members.slice(0, 8);\n  return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);\n}\n\n/**\n * Insert a chosen member as an `@name ` token, replacing the active `@query`.\n * Returns the new text + the caret position after the inserted token.\n */\nexport function insertMention(\n  text: string,\n  active: ActiveMention,\n  member: BlackboardMember,\n): { text: string; caret: number } {\n  const token = `@${member.name}`;\n  const before = text.slice(0, active.at);\n  const after = text.slice(active.at + 1 + active.query.length);\n  const insert = `${token} `;\n  return { text: before + insert + after, caret: before.length + insert.length };\n}\n\n/**\n * Re-scan the final text against the roster and produce the canonical `mentions[]`\n * (offset-anchored). Longest names first so \"@Anna Lee\" wins over \"@Anna\".\n */\nexport function extractMentions(text: string, members: BlackboardMember[]): Mention[] {\n  const byLongest = [...members].sort((a, b) => b.name.length - a.name.length);\n  const out: Mention[] = [];\n  const claimed: boolean[] = new Array(text.length).fill(false);\n\n  for (const m of byLongest) {\n    const needle = `@${m.name}`;\n    let from = 0;\n    for (;;) {\n      const idx = text.indexOf(needle, from);\n      if (idx === -1) break;\n      const end = idx + needle.length;\n      const prevOk = idx === 0 || MENTION_BOUNDARY.test(text[idx - 1]);\n      const nextOk = end >= text.length || MENTION_BOUNDARY.test(text[end]);\n      const free = !claimed.slice(idx, end).some(Boolean);\n      if (prevOk && nextOk && free) {\n        out.push({ memberId: m.id, display: needle, start: idx, length: needle.length });\n        for (let k = idx; k < end; k++) claimed[k] = true;\n      }\n      from = idx + 1;\n    }\n  }\n  return out.sort((a, b) => a.start - b.start);\n}\n\n/** Unique member ids referenced by a mention list. */\nexport function dedupeMemberIds(mentions: Mention[]): string[] {\n  return Array.from(new Set(mentions.map((m) => m.memberId)));\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/lib/mentions.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/lib/palette.ts",
      "content": "import type { InkColor, NoteStyle, NoteWidth } from \"../types\";\n\n/**\n * Curated chalk-tone ink palette. Muted (chroma ≤ 0.20), legible on the dark-navy\n * board, token-aligned. Overridable via the `palette` prop. Keeps the wall coherent\n * vs. a free color wheel (which is an opt-in via `allowFreeColor`).\n */\nexport const DEFAULT_PALETTE: InkColor[] = [\n  { key: \"chalk\", label: \"Chalk\", value: \"oklch(0.96 0.01 250)\" },\n  { key: \"lime\", label: \"Lime\", value: \"oklch(0.86 0.18 132)\" },\n  { key: \"sky\", label: \"Sky\", value: \"oklch(0.82 0.12 230)\" },\n  { key: \"amber\", label: \"Amber\", value: \"oklch(0.84 0.14 80)\" },\n  { key: \"rose\", label: \"Rose\", value: \"oklch(0.80 0.14 18)\" },\n];\n\nexport const DEFAULT_WIDTHS: NoteWidth[] = [\"thin\", \"regular\", \"bold\"];\n\n/** The chalk-red used for the unread number (semantic, chroma-capped). */\nexport const UNREAD_RED = \"oklch(0.66 0.19 22)\";\n\nexport const DEFAULT_NOTE_STYLE: NoteStyle = {\n  color: \"chalk\",\n  width: \"regular\",\n  font: \"kalam\",\n};\n\n/** Resolve a note's stored color key (or raw color) to a CSS color. */\nexport function resolveInk(color: string, palette: InkColor[]): string {\n  const hit = palette.find((p) => p.key === color);\n  return hit ? hit.value : color; // raw CSS color passthrough (free-color mode)\n}\n\n/** Map a width level to a real font-weight. */\nexport function weightForWidth(width: NoteWidth): number {\n  return width === \"thin\" ? 300 : width === \"bold\" ? 700 : 400;\n}\n\n/** Faux chalk-stroke width (px) for single-weight fonts. */\nexport function strokeForWidth(width: NoteWidth): number {\n  return width === \"thin\" ? 0 : width === \"bold\" ? 0.9 : 0.4;\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/lib/palette.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/lib/unread.ts",
      "content": "import type { BlackboardNote } from \"../types\";\n\n/**\n * Derive the unread count: notes strictly after `lastSeenNoteId` in the stream.\n * `notes` is oldest → newest. A null/unknown lastSeen ⇒ everything is unread.\n * Pending/failed optimistic notes never count.\n */\nexport function deriveUnread(\n  notes: BlackboardNote[],\n  lastSeenNoteId: string | null | undefined,\n): number {\n  const real = notes.filter((n) => !n.pending && !n.failed);\n  if (!lastSeenNoteId) return real.length;\n  const idx = real.findIndex((n) => n.id === lastSeenNoteId);\n  if (idx === -1) return real.length; // last-seen note no longer present → treat all as unread\n  return real.length - idx - 1;\n}\n\n/** The newest real note's id, or null. */\nexport function latestNoteId(notes: BlackboardNote[]): string | null {\n  for (let i = notes.length - 1; i >= 0; i--) {\n    if (!notes[i].pending && !notes[i].failed) return notes[i].id;\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/lib/unread.ts"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-background-editor.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Image as ImageIcon, Palette } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\n\nexport interface BlackboardBackgroundEditorProps {\n  className?: string;\n}\n\nconst BOARD_COLORS = [\n  \"oklch(0.18 0.04 250)\", // navy (default)\n  \"oklch(0.20 0.02 160)\", // green slate\n  \"oklch(0.17 0.01 20)\", // charcoal\n  \"oklch(0.19 0.03 300)\", // plum\n];\n\n/**\n * Opt-in board theming control (top-left). Renders nothing unless `editableBackground`.\n * Inline panel (no Popover primitive — avoids the Base-UI divergence surface for an\n * opt-in affordance). Sets a solid color or a custom image URL via context.\n */\nexport function BlackboardBackgroundEditor({ className }: BlackboardBackgroundEditorProps) {\n  const ctx = useBlackboard();\n  const [open, setOpen] = useState(false);\n  if (!ctx.editableBackground) return null;\n\n  const bg = ctx.background;\n  return (\n    <div className={cn(\"absolute left-2 top-2 z-20\", className)}>\n      <Button\n        type=\"button\"\n        size=\"icon-sm\"\n        variant=\"ghost\"\n        aria-label={ctx.labels.backgroundLabel}\n        aria-expanded={open}\n        onClick={() => setOpen((o) => !o)}\n        className=\"text-white/70 hover:bg-white/10 hover:text-white\"\n      >\n        <Palette aria-hidden />\n      </Button>\n      {open ? (\n        <div\n          role=\"dialog\"\n          aria-label={ctx.labels.backgroundLabel}\n          className=\"absolute left-0 top-9 w-60 rounded-lg border border-white/10 bg-[oklch(0.22_0.02_250)] p-3 text-white shadow-xl\"\n        >\n          <div className=\"mb-2 text-xs font-medium text-white/60\">{ctx.labels.backgroundColor}</div>\n          <div className=\"flex gap-1.5\">\n            {BOARD_COLORS.map((c) => {\n              const selected = bg.kind === \"color\" && bg.value === c;\n              return (\n                <button\n                  key={c}\n                  type=\"button\"\n                  aria-label={c}\n                  onClick={() => ctx.setBackground({ kind: \"color\", value: c })}\n                  className={cn(\n                    \"size-6 rounded-full ring-1 ring-white/15 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]\",\n                    selected && \"ring-2 ring-white/70\",\n                  )}\n                  style={{ backgroundColor: c }}\n                />\n              );\n            })}\n          </div>\n          <div className=\"mt-3 mb-1 flex items-center gap-1 text-xs font-medium text-white/60\">\n            <ImageIcon className=\"size-3\" aria-hidden />\n            {ctx.labels.backgroundImage}\n          </div>\n          <input\n            type=\"url\"\n            placeholder={ctx.labels.backgroundImageUrl}\n            defaultValue={bg.kind === \"image\" ? bg.url : \"\"}\n            onChange={(e) => {\n              const url = e.target.value.trim();\n              if (url) ctx.setBackground({ kind: \"image\", url, overlay: 0.45 });\n            }}\n            className=\"w-full rounded-md border border-white/10 bg-white/5 px-2 py-1 text-sm text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-white/30\"\n          />\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-background-editor.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-composer.tsx",
      "content": "\"use client\";\n\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { NoteComposer } from \"./note-composer\";\n\nexport interface BlackboardComposerProps {\n  className?: string;\n}\n\n/**\n * Context-wired composer. In `double-click` mode it renders only while open (revealed\n * by double-clicking the board), auto-focuses on reveal, and offers a ✕ / Escape to\n * dismiss. In `always` mode it stays docked. Reads draft + writing state from context.\n */\nexport function BlackboardComposer({ className }: BlackboardComposerProps) {\n  const ctx = useBlackboard();\n  const doubleClick = ctx.composerMode === \"double-click\";\n  if (doubleClick && !ctx.composerOpen) return null;\n\n  return (\n    <NoteComposer\n      draft={ctx.draft}\n      onChangeText={ctx.setDraftText}\n      onChangeStyle={ctx.setDraftStyle}\n      onPost={ctx.post}\n      palette={ctx.palette}\n      fonts={ctx.fonts}\n      widths={ctx.widths}\n      members={ctx.members}\n      allowFreeColor={ctx.allowFreeColor}\n      canWrite={ctx.canWrite}\n      posting={ctx.posting}\n      autoFocus={doubleClick}\n      onClose={doubleClick ? ctx.closeComposer : undefined}\n      labels={ctx.labels}\n      textareaRef={ctx.composerRef}\n      renderWriteDenied={ctx.renderWriteDenied}\n      className={className}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-composer.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-note-item.tsx",
      "content": "\"use client\";\n\nimport { Pin, PinOff, RotateCcw, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { HandwrittenNote } from \"./handwritten-note\";\nimport type { BlackboardNote } from \"../types\";\n\nexport interface BlackboardNoteItemProps {\n  note: BlackboardNote;\n  className?: string;\n}\n\n/**\n * One note + its capability-gated affordances (retry / pin / delete). Reads context;\n * wraps the dumb `HandwrittenNote`. Affordances appear on hover/focus only.\n */\nexport function BlackboardNoteItem({ note, className }: BlackboardNoteItemProps) {\n  const ctx = useBlackboard();\n  const pinned = ctx.isPinned(note.id);\n\n  const actions = (\n    <>\n      {note.failed ? (\n        <Button\n          type=\"button\"\n          size=\"icon-xs\"\n          variant=\"ghost\"\n          aria-label={ctx.labels.retry}\n          title={ctx.labels.retry}\n          onClick={() => ctx.retryPost(note.id)}\n          className=\"text-white/75 hover:bg-white/15 hover:text-white\"\n        >\n          <RotateCcw aria-hidden />\n        </Button>\n      ) : null}\n      {ctx.canPin && !note.pending && !note.failed ? (\n        <Button\n          type=\"button\"\n          size=\"icon-xs\"\n          variant=\"ghost\"\n          aria-label={pinned ? ctx.labels.unpin : ctx.labels.pin}\n          title={pinned ? ctx.labels.unpin : ctx.labels.pin}\n          onClick={() => ctx.togglePin(note)}\n          className=\"text-white/75 hover:bg-white/15 hover:text-white\"\n        >\n          {pinned ? <PinOff aria-hidden /> : <Pin aria-hidden />}\n        </Button>\n      ) : null}\n      {ctx.canDelete || note.failed ? (\n        <Button\n          type=\"button\"\n          size=\"icon-xs\"\n          variant=\"ghost\"\n          aria-label={ctx.labels.delete}\n          title={ctx.labels.delete}\n          onClick={() => ctx.deleteNote(note)}\n          className=\"text-white/65 hover:bg-white/15 hover:text-white\"\n        >\n          <Trash2 aria-hidden />\n        </Button>\n      ) : null}\n    </>\n  );\n\n  const hasActions = !!(note.failed || (ctx.canPin && !note.pending) || ctx.canDelete);\n\n  return (\n    <HandwrittenNote\n      note={note}\n      palette={ctx.palette}\n      fonts={ctx.fonts}\n      showAuthor={ctx.showAuthorOnHover}\n      isMentioned={ctx.mentionsCurrentUser(note)}\n      mentionYouLabel={ctx.labels.mentionYou}\n      actions={hasActions ? actions : undefined}\n      className={className}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-note-item.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-note-stream.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { UIEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { BlackboardNoteItem } from \"./blackboard-note-item\";\n\nexport interface BlackboardNoteStreamProps {\n  className?: string;\n}\n\n/**\n * The scrollable note stream. Owns the scroll container (the board's scroll target),\n * the top lazy-load sentinel (wired by the controller's IntersectionObserver), the\n * empty state, mark-as-seen on reaching the bottom, and a dedicated SR live region\n * that announces only genuinely-new (bottom) notes — never lazy-loaded older ones.\n */\nexport function BlackboardNoteStream({ className }: BlackboardNoteStreamProps) {\n  // Destructure context into locals so refs are used only in `ref=` prop form\n  // (the react-hooks/refs rule flags ref reads taken off a bundled context object).\n  const {\n    streamNotes,\n    pinnedNotes,\n    newestFirst,\n    scrollRef,\n    sentinelRef,\n    loadOlderEnabled,\n    hasMoreOlder,\n    loadingOlder,\n    labels,\n    renderEmpty,\n    onReachedBottom,\n  } = useBlackboard();\n\n  const displayed = useMemo(\n    () => (newestFirst ? [...streamNotes].reverse() : streamNotes),\n    [streamNotes, newestFirst],\n  );\n\n  const handleScroll = useCallback(\n    (e: UIEvent<HTMLDivElement>) => {\n      const el = e.currentTarget;\n      if (el.scrollHeight - el.scrollTop - el.clientHeight < 24) onReachedBottom();\n    },\n    [onReachedBottom],\n  );\n\n  // Announce only the newest (chronological) note when it changes — prepended older\n  // notes change the *first* element, not the last, so they don't trigger this.\n  const newest = streamNotes.length ? streamNotes[streamNotes.length - 1] : null;\n  const newestId = newest?.id ?? null;\n  const newestPending = newest?.pending ?? false;\n  const announceText = newest ? `${newest.author.name}: ${newest.text}` : \"\";\n  const prevNewestId = useRef<string | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  useEffect(() => {\n    if (newestId && newestId !== prevNewestId.current) {\n      if (prevNewestId.current !== null && !newestPending) setAnnounce(announceText);\n      prevNewestId.current = newestId;\n    }\n  }, [newestId, newestPending, announceText]);\n\n  const empty = streamNotes.length === 0 && pinnedNotes.length === 0;\n  const showSentinel = loadOlderEnabled && hasMoreOlder;\n  const sentinelLabel = loadingOlder ? labels.loadingOlder : labels.loadOlder;\n\n  return (\n    <div\n      ref={scrollRef}\n      onScroll={handleScroll}\n      role=\"log\"\n      aria-label=\"Board notes\"\n      className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2\", className)}\n    >\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </div>\n\n      {showSentinel && !newestFirst ? (\n        <div ref={sentinelRef} className=\"flex items-center justify-center py-1.5\">\n          <span className={cn(\"text-xs text-white/35\", loadingOlder && \"animate-pulse\")}>\n            {sentinelLabel}\n          </span>\n        </div>\n      ) : null}\n\n      {empty ? (\n        <div\n          className=\"flex h-full min-h-32 items-center justify-center text-center text-2xl text-white/40\"\n          style={{ fontFamily: \"var(--bb-font-caveat)\" }}\n        >\n          {renderEmpty ? renderEmpty() : labels.empty}\n        </div>\n      ) : (\n        <div className=\"flex flex-col gap-0.5\">\n          {displayed.map((note) => (\n            <BlackboardNoteItem key={note.id} note={note} />\n          ))}\n        </div>\n      )}\n\n      {showSentinel && newestFirst ? (\n        <div ref={sentinelRef} className=\"flex items-center justify-center py-1.5\">\n          <span className={cn(\"text-xs text-white/35\", loadingOlder && \"animate-pulse\")}>\n            {sentinelLabel}\n          </span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-note-stream.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-notification-badge.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { UnreadCount } from \"./unread-count\";\n\nexport interface BlackboardNotificationBadgeProps {\n  className?: string;\n}\n\n/**\n * The handwritten red unread marker, positioned top-right of the board by default.\n * Reads `unreadCount` from context; clicking scrolls to the latest + marks all seen.\n */\nexport function BlackboardNotificationBadge({ className }: BlackboardNotificationBadgeProps) {\n  const ctx = useBlackboard();\n  if (ctx.unreadCount <= 0) return null;\n  return (\n    <div className={cn(\"pointer-events-none absolute right-2 top-2 z-20\", className)}>\n      <div className=\"pointer-events-auto\">\n        <UnreadCount\n          count={ctx.unreadCount}\n          ariaLabel={ctx.labels.unreadAria}\n          onClick={() => {\n            ctx.scrollToLatest();\n            ctx.markAllSeen();\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-notification-badge.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-pinned-row.tsx",
      "content": "\"use client\";\n\nimport { Pin } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { BlackboardNoteItem } from \"./blackboard-note-item\";\n\nexport interface BlackboardPinnedRowProps {\n  className?: string;\n}\n\n/** Sticky pinned-notes band above the stream. Reads context; renders nothing when empty. */\nexport function BlackboardPinnedRow({ className }: BlackboardPinnedRowProps) {\n  const ctx = useBlackboard();\n  if (ctx.pinnedNotes.length === 0) return null;\n  return (\n    <div className={cn(\"shrink-0 border-b border-white/10 bg-white/3 px-2 py-1.5\", className)}>\n      <div className=\"mb-1 flex items-center gap-1 px-1 text-[0.7rem] font-medium uppercase tracking-wide text-white/40\">\n        <Pin className=\"size-3\" aria-hidden />\n        {ctx.labels.pinnedHeading}\n      </div>\n      <div className=\"flex flex-col gap-0.5\">\n        {ctx.pinnedNotes.map((note) => (\n          <BlackboardNoteItem key={note.id} note={note} />\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-pinned-row.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-root.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { BlackboardRootProps } from \"../types\";\nimport { BlackboardContext } from \"../hooks/use-blackboard\";\nimport { useBlackboardController } from \"../hooks/use-blackboard-state\";\n// Side-effect: registers the bundled handwriting @font-face rules + exposes the\n// default --bb-font-* declarations. Importing here loads fonts on any board mount.\nimport { FONT_VAR_DECLARATIONS } from \"../blackboard-fonts\";\n\n/**\n * Headless provider. Owns all state + handlers + the imperative handle (via\n * `useBlackboardController`) and injects the `--bb-font-*` CSS vars on its wrapper\n * so descendants resolve the handwriting families without touching app-global CSS.\n * Renders `children` only — no board chrome (that's `BlackboardSurface` + parts).\n */\nexport function BlackboardRoot(props: BlackboardRootProps) {\n  const ctx = useBlackboardController(props);\n  const { className, style, children } = props;\n\n  const fontVars = FONT_VAR_DECLARATIONS as Record<`--${string}`, string>;\n  const mergedStyle = { ...fontVars, ...style } as CSSProperties;\n\n  return (\n    <BlackboardContext.Provider value={ctx}>\n      <div data-slot=\"blackboard\" className={cn(\"flex min-h-0 flex-col\", className)} style={mergedStyle}>\n        {children}\n      </div>\n    </BlackboardContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-root.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/blackboard-surface.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useBlackboard } from \"../hooks/use-blackboard\";\nimport { BoardBackground } from \"./board-background\";\n\nexport interface BlackboardSurfaceProps {\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * The visible board — a dark, rounded, self-contained surface (chalkboard identity:\n * dark in both themes). Lays its children out as a column (pinned row / stream /\n * composer) over the themeable background, and is a `@container` so the chrome can\n * adapt to a narrow dashboard tile. In `double-click` composer mode, double-clicking\n * the surface reveals the composer; a faint hint advertises it.\n */\nexport function BlackboardSurface({ children, className }: BlackboardSurfaceProps) {\n  const { background, canWrite, composerMode, composerOpen, openComposer, labels } =\n    useBlackboard();\n\n  const doubleClickEnabled = composerMode === \"double-click\" && canWrite;\n\n  return (\n    <div\n      onDoubleClick={doubleClickEnabled ? () => openComposer() : undefined}\n      className={cn(\n        \"@container/board relative isolate flex h-full min-h-80 flex-col overflow-hidden rounded-xl border border-white/10 text-white shadow-sm\",\n        className,\n      )}\n    >\n      <BoardBackground background={background} className=\"absolute inset-0 -z-10 size-full\" />\n      {children}\n      {doubleClickEnabled && !composerOpen ? (\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center\">\n          <span className=\"rounded-full bg-black/25 px-2.5 py-1 text-[0.7rem] text-white/40 backdrop-blur-sm\">\n            {labels.doubleClickHint}\n          </span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/blackboard-surface.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/board-background.tsx",
      "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { BoardBackground as BoardBackgroundValue } from \"../types\";\n\nexport interface BoardBackgroundProps {\n  background: BoardBackgroundValue;\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * Renders the board surface — a solid color or a custom image with a darkening\n * overlay (so chalk stays legible) — plus a subtle chalk-dust vignette. Pure +\n * context-free. The vignette is pure CSS (no image asset) to keep it light.\n */\nexport function BoardBackground({ background, children, className }: BoardBackgroundProps) {\n  const isImage = background.kind === \"image\";\n  const overlay = isImage ? background.overlay ?? 0.45 : 0;\n\n  const surfaceStyle: CSSProperties = isImage\n    ? { backgroundImage: `url(${background.url})`, backgroundSize: \"cover\", backgroundPosition: \"center\" }\n    : { backgroundColor: background.value };\n\n  return (\n    <div className={cn(\"relative isolate overflow-hidden\", className)} style={surfaceStyle}>\n      {/* darkening scrim for image backgrounds */}\n      {isImage ? (\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 -z-10 bg-black\"\n          style={{ opacity: overlay }}\n        />\n      ) : null}\n      {/* chalk-dust vignette — pure CSS, very subtle */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 -z-10\"\n        style={{\n          background:\n            \"radial-gradient(120% 80% at 50% -10%, rgba(255,255,255,0.05), transparent 60%),\" +\n            \"radial-gradient(100% 100% at 50% 120%, rgba(0,0,0,0.28), transparent 55%)\",\n        }}\n      />\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/board-background.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/chalk-width-picker.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { NoteWidth } from \"../types\";\n\nexport interface ChalkWidthPickerProps {\n  widths: NoteWidth[];\n  value: NoteWidth;\n  onChange: (width: NoteWidth) => void;\n  label?: string;\n  className?: string;\n}\n\nconst STROKE_PX: Record<NoteWidth, number> = { thin: 1.5, regular: 3, bold: 5 };\n\n/** Chalk-thickness picker — a glyph of increasing stroke per level. Pure + context-free. */\nexport function ChalkWidthPicker({\n  widths,\n  value,\n  onChange,\n  label = \"Chalk width\",\n  className,\n}: ChalkWidthPickerProps) {\n  return (\n    <div role=\"radiogroup\" aria-label={label} className={cn(\"flex items-center gap-0.5\", className)}>\n      {widths.map((w) => {\n        const selected = w === value;\n        return (\n          <button\n            key={w}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            aria-label={w}\n            title={w}\n            onClick={() => onChange(w)}\n            className={cn(\n              \"flex size-6 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]\",\n              selected ? \"bg-white/15\" : \"hover:bg-white/8\",\n            )}\n          >\n            <span\n              aria-hidden\n              className=\"block w-3.5 rounded-full bg-white/80\"\n              style={{ height: STROKE_PX[w] }}\n            />\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/chalk-width-picker.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/handwriting-font-picker.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { HandwritingFont } from \"../types\";\n\nexport interface HandwritingFontPickerProps {\n  fonts: HandwritingFont[];\n  value: string;\n  onChange: (fontKey: string) => void;\n  label?: string;\n  className?: string;\n}\n\n/** Handwriting-font picker — each option previewed in its own face. Pure + context-free. */\nexport function HandwritingFontPicker({\n  fonts,\n  value,\n  onChange,\n  label = \"Handwriting\",\n  className,\n}: HandwritingFontPickerProps) {\n  return (\n    <div role=\"radiogroup\" aria-label={label} className={cn(\"flex items-center gap-0.5\", className)}>\n      {fonts.map((font) => {\n        const selected = font.key === value;\n        return (\n          <button\n            key={font.key}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            aria-label={font.label}\n            title={font.label}\n            onClick={() => onChange(font.key)}\n            style={{ fontFamily: `var(${font.cssVar})` }}\n            className={cn(\n              \"flex h-6 min-w-6 items-center justify-center rounded-md px-1.5 text-lg leading-none text-white/85 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]\",\n              selected ? \"bg-white/15 text-white\" : \"hover:bg-white/8\",\n            )}\n          >\n            {font.label.charAt(0)}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/handwriting-font-picker.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/handwritten-note.tsx",
      "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { formatDistanceToNowStrict } from \"date-fns\";\nimport { cn } from \"@/lib/utils\";\nimport type { BlackboardNote, HandwritingFont, InkColor } from \"../types\";\nimport { resolveInk, strokeForWidth, weightForWidth } from \"../lib/palette\";\nimport { MentionText } from \"./mention-text\";\n\nexport interface HandwrittenNoteProps {\n  note: BlackboardNote;\n  palette: InkColor[];\n  fonts: HandwritingFont[];\n  /** Show the faint inline author label on hover/focus. */\n  showAuthor?: boolean;\n  /** Emphasise a note that @mentions the viewer. */\n  isMentioned?: boolean;\n  mentionYouLabel?: string;\n  /** Affordance slot (pin / delete buttons), rendered top-right on hover. */\n  actions?: ReactNode;\n  className?: string;\n}\n\n/**\n * A single chalk-written note — pure, context-free. Renders the text in the\n * author's ink color, chalk width, and handwriting font, with a soft chalk-dust\n * shadow and a faint inline author label that fades in on hover/focus.\n */\nexport function HandwrittenNote({\n  note,\n  palette,\n  fonts,\n  showAuthor = true,\n  isMentioned = false,\n  mentionYouLabel = \"@you\",\n  actions,\n  className,\n}: HandwrittenNoteProps) {\n  const ink = resolveInk(note.style.color, palette);\n  const font = fonts.find((f) => f.key === note.style.font) ?? fonts[0];\n  const hasWeights = font?.hasWeights ?? false;\n  const stroke = hasWeights ? 0 : strokeForWidth(note.style.width);\n\n  const inkStyle: CSSProperties = {\n    color: ink,\n    fontFamily: font ? `var(${font.cssVar})` : \"cursive\",\n    fontWeight: hasWeights ? weightForWidth(note.style.width) : 400,\n    WebkitTextStrokeWidth: stroke > 0 ? `${stroke}px` : undefined,\n    WebkitTextStrokeColor: stroke > 0 ? ink : undefined,\n    textShadow: \"0 0.5px 0.6px rgba(255,255,255,0.07)\",\n  };\n\n  const time = (() => {\n    try {\n      return formatDistanceToNowStrict(new Date(note.createdAt), { addSuffix: false });\n    } catch {\n      return \"\";\n    }\n  })();\n\n  return (\n    <div\n      className={cn(\n        \"group/note relative flex flex-col gap-0.5 rounded-md px-2 py-1.5 transition-colors\",\n        \"focus-within:bg-white/5 hover:bg-white/5\",\n        note.pending && \"opacity-60\",\n        className,\n      )}\n      tabIndex={0}\n      data-pending={note.pending ? \"\" : undefined}\n      data-failed={note.failed ? \"\" : undefined}\n    >\n      {actions ? (\n        <div className=\"absolute right-1 top-1 z-10 flex items-center gap-0.5 rounded-md bg-black/30 p-0.5 opacity-0 backdrop-blur-sm transition-opacity group-focus-within/note:opacity-100 group-hover/note:opacity-100\">\n          {actions}\n        </div>\n      ) : null}\n\n      <p\n        className=\"text-[1.35rem] leading-snug wrap-break-word text-pretty\"\n        style={inkStyle}\n      >\n        <MentionText\n          text={note.text}\n          mentions={note.mentions}\n          mentionClassName=\"opacity-90\"\n        />\n      </p>\n\n      <div className=\"flex min-h-3.5 items-center gap-1.5 text-[0.7rem] text-white/35\">\n        {isMentioned ? (\n          <span\n            className=\"rounded-full px-1 font-medium\"\n            style={{ color: \"oklch(0.66 0.19 22)\", fontFamily: font ? `var(${font.cssVar})` : \"cursive\" }}\n          >\n            {mentionYouLabel}\n          </span>\n        ) : null}\n        {showAuthor ? (\n          <span\n            className=\"inline-flex items-center gap-1 opacity-0 transition-opacity duration-200 group-focus-within/note:opacity-100 group-hover/note:opacity-100\"\n            title={`${note.author.name} · ${new Date(note.createdAt).toLocaleString()}`}\n            suppressHydrationWarning\n          >\n            <span\n              className=\"inline-block size-1.5 rounded-full\"\n              style={{ backgroundColor: note.author.inkColor ?? ink }}\n              aria-hidden\n            />\n            <span className=\"font-medium text-white/55\">{note.author.name}</span>\n            {time ? <span aria-hidden>· {time}</span> : null}\n          </span>\n        ) : null}\n        {note.failed ? <span className=\"text-[oklch(0.66_0.19_22)]\">· failed</span> : null}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/handwritten-note.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/ink-color-picker.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { InkColor } from \"../types\";\n\nexport interface InkColorPickerProps {\n  palette: InkColor[];\n  value: string;\n  onChange: (colorKey: string) => void;\n  /** Append a native color input for free-hex picking. */\n  allowFreeColor?: boolean;\n  label?: string;\n  className?: string;\n}\n\n/** Curated chalk-ink swatch row. Pure + context-free (`value`/`onChange`). */\nexport function InkColorPicker({\n  palette,\n  value,\n  onChange,\n  allowFreeColor = false,\n  label = \"Ink color\",\n  className,\n}: InkColorPickerProps) {\n  return (\n    <div role=\"radiogroup\" aria-label={label} className={cn(\"flex items-center gap-1\", className)}>\n      {palette.map((ink) => {\n        const selected = ink.key === value;\n        return (\n          <button\n            key={ink.key}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            aria-label={ink.label}\n            title={ink.label}\n            onClick={() => onChange(ink.key)}\n            className={cn(\n              \"size-5 rounded-full ring-offset-1 ring-offset-transparent transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]\",\n              selected ? \"scale-110 ring-2 ring-white/70\" : \"ring-1 ring-white/15 hover:scale-105\",\n            )}\n            style={{ backgroundColor: ink.value }}\n          />\n        );\n      })}\n      {allowFreeColor ? (\n        <label\n          className=\"ml-0.5 inline-flex size-5 cursor-pointer items-center justify-center rounded-full ring-1 ring-white/15\"\n          title=\"Custom color\"\n          style={{\n            background:\n              \"conic-gradient(from 0deg, oklch(0.8 0.2 30), oklch(0.8 0.2 130), oklch(0.8 0.2 230), oklch(0.8 0.2 330), oklch(0.8 0.2 30))\",\n          }}\n        >\n          <input\n            type=\"color\"\n            aria-label=\"Custom ink color\"\n            className=\"size-0 opacity-0\"\n            onChange={(e) => onChange(e.target.value)}\n          />\n        </label>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/ink-color-picker.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/mention-picker.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { BlackboardMember } from \"../types\";\n\nexport interface MentionPickerProps {\n  members: BlackboardMember[];\n  highlight: number;\n  onHighlight: (i: number) => void;\n  onSelect: (member: BlackboardMember) => void;\n  className?: string;\n}\n\n/**\n * The `@`-mention candidate list. Dumb + context-free; rendered by the composer\n * (anchored to it, NOT a Popover primitive). Uses `onMouseDown` + preventDefault\n * so picking doesn't blur the textarea first.\n */\nexport function MentionPicker({\n  members,\n  highlight,\n  onHighlight,\n  onSelect,\n  className,\n}: MentionPickerProps) {\n  if (members.length === 0) return null;\n  return (\n    <ul\n      role=\"listbox\"\n      aria-label=\"Mention a teammate\"\n      className={cn(\n        \"max-h-48 w-56 overflow-auto rounded-lg border border-white/10 bg-[oklch(0.22_0.02_250)] p-1 shadow-xl\",\n        className,\n      )}\n    >\n      {members.map((member, i) => {\n        const active = i === highlight;\n        return (\n          <li\n            key={member.id}\n            role=\"option\"\n            aria-selected={active}\n            onMouseEnter={() => onHighlight(i)}\n            onMouseDown={(e) => {\n              e.preventDefault();\n              onSelect(member);\n            }}\n            className={cn(\n              \"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-white/80\",\n              active ? \"bg-white/12 text-white\" : \"hover:bg-white/8\",\n            )}\n          >\n            <span\n              aria-hidden\n              className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[0.65rem] font-medium uppercase text-white/70\"\n            >\n              {member.name.charAt(0)}\n            </span>\n            <span className=\"truncate\">{member.name}</span>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/mention-picker.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/mention-text.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { Mention } from \"../types\";\n\nexport interface MentionTextProps {\n  text: string;\n  mentions?: Mention[];\n  className?: string;\n  /** Extra classes for the mention tokens. */\n  mentionClassName?: string;\n}\n\n/**\n * Renders note text with `@mention` tokens styled. Pure + context-free.\n * Offset-anchored (uses `mention.start`/`length`), tolerant of overlaps and\n * out-of-range offsets.\n */\nexport function MentionText({ text, mentions, className, mentionClassName }: MentionTextProps) {\n  if (!mentions || mentions.length === 0) {\n    return <span className={className}>{text}</span>;\n  }\n  const sorted = [...mentions].sort((a, b) => a.start - b.start);\n  const out: ReactNode[] = [];\n  let cursor = 0;\n  sorted.forEach((m, i) => {\n    if (m.start < cursor || m.start > text.length) return; // overlap / out-of-range guard\n    if (m.start > cursor) out.push(<span key={`t${i}`}>{text.slice(cursor, m.start)}</span>);\n    const end = Math.min(m.start + m.length, text.length);\n    out.push(\n      <span\n        key={`m${i}`}\n        data-mention=\"\"\n        className={cn(\"font-semibold underline decoration-dotted underline-offset-2\", mentionClassName)}\n      >\n        {text.slice(m.start, end)}\n      </span>,\n    );\n    cursor = end;\n  });\n  if (cursor < text.length) out.push(<span key=\"tail\">{text.slice(cursor)}</span>);\n  return <span className={className}>{out}</span>;\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/mention-text.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/note-composer.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { SendHorizontal, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  BlackboardLabels,\n  BlackboardMember,\n  HandwritingFont,\n  InkColor,\n  NoteDraft,\n  NoteStyle,\n  NoteWidth,\n} from \"../types\";\nimport { resolveInk } from \"../lib/palette\";\nimport { useMentions } from \"../hooks/use-mentions\";\nimport { InkColorPicker } from \"./ink-color-picker\";\nimport { ChalkWidthPicker } from \"./chalk-width-picker\";\nimport { HandwritingFontPicker } from \"./handwriting-font-picker\";\nimport { MentionPicker } from \"./mention-picker\";\n\nexport interface NoteComposerProps {\n  draft: NoteDraft;\n  onChangeText: (text: string) => void;\n  onChangeStyle: (patch: Partial<NoteStyle>) => void;\n  onPost: () => void;\n  palette: InkColor[];\n  fonts: HandwritingFont[];\n  widths: NoteWidth[];\n  members?: BlackboardMember[];\n  allowFreeColor?: boolean;\n  canWrite?: boolean;\n  posting?: boolean;\n  /** Focus the textarea on mount (used when revealed by double-click). */\n  autoFocus?: boolean;\n  /** When provided, renders a dismiss (✕) control and closes on Escape. */\n  onClose?: () => void;\n  labels?: Partial<BlackboardLabels>;\n  /** Ref to the underlying textarea (so the board can focus it). */\n  textareaRef?: RefObject<HTMLTextAreaElement | null>;\n  renderWriteDenied?: () => ReactNode;\n  className?: string;\n}\n\n/**\n * The composer — a borderless chalk-line textarea (previewed in the chosen ink +\n * handwriting font), three understated writing pickers, an `@`-mention picker, and\n * Post. Minimal by design: no tray fill, no dividers, just a hairline. Dumb +\n * context-free; the board wires it via `BlackboardComposer`.\n */\nexport function NoteComposer({\n  draft,\n  onChangeText,\n  onChangeStyle,\n  onPost,\n  palette,\n  fonts,\n  widths,\n  members = [],\n  allowFreeColor = false,\n  canWrite = true,\n  posting = false,\n  autoFocus = false,\n  onClose,\n  labels,\n  textareaRef,\n  renderWriteDenied,\n  className,\n}: NoteComposerProps) {\n  const internalRef = textareaRef ?? { current: null };\n  const mentions = useMentions({\n    textareaRef: internalRef,\n    text: draft.text,\n    members,\n    setText: onChangeText,\n  });\n\n  if (!canWrite) {\n    return (\n      <div className={cn(\"border-t border-white/10 px-3 py-3 text-sm text-white/50\", className)}>\n        {renderWriteDenied ? renderWriteDenied() : \"You don't have permission to write here.\"}\n      </div>\n    );\n  }\n\n  const ink = resolveInk(draft.style.color, palette);\n  const font = fonts.find((f) => f.key === draft.style.font) ?? fonts[0];\n  const previewStyle: CSSProperties = {\n    color: ink,\n    fontFamily: font ? `var(${font.cssVar})` : \"cursive\",\n  };\n\n  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (mentions.onKeyDown(e)) return; // mention nav consumed it\n    if (e.key === \"Escape\" && onClose) {\n      e.preventDefault();\n      onClose();\n      return;\n    }\n    if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      onPost();\n    }\n  };\n\n  const canPost = draft.text.trim().length > 0 && !posting;\n\n  return (\n    <div className={cn(\"relative border-t border-white/10 px-3 pb-2.5 pt-2\", className)}>\n      {mentions.active && mentions.candidates.length > 0 ? (\n        <div className=\"absolute bottom-full left-3 z-20 mb-1\">\n          <MentionPicker\n            members={mentions.candidates}\n            highlight={mentions.highlight}\n            onHighlight={mentions.setHighlight}\n            onSelect={mentions.choose}\n          />\n        </div>\n      ) : null}\n\n      <Textarea\n        ref={internalRef}\n        autoFocus={autoFocus}\n        value={draft.text}\n        onChange={(e) => onChangeText(e.target.value)}\n        onKeyDown={handleKeyDown}\n        onKeyUp={mentions.refresh}\n        onClick={mentions.refresh}\n        onBlur={() => setTimeout(mentions.close, 100)}\n        rows={2}\n        placeholder={labels?.composerPlaceholder ?? \"Write a note…\"}\n        aria-label={labels?.composerPlaceholder ?? \"Write a note\"}\n        className=\"min-h-11 resize-none rounded-none border-0 border-b border-white/10 bg-transparent px-1 text-xl leading-snug placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0\"\n        style={previewStyle}\n      />\n\n      <div className=\"mt-2 flex items-center justify-between gap-2\">\n        <div className=\"flex items-center gap-2.5\">\n          <InkColorPicker\n            palette={palette}\n            value={draft.style.color}\n            onChange={(color) => onChangeStyle({ color })}\n            allowFreeColor={allowFreeColor}\n            label={labels?.colorLabel}\n          />\n          <ChalkWidthPicker\n            widths={widths}\n            value={draft.style.width}\n            onChange={(width) => onChangeStyle({ width })}\n            label={labels?.widthLabel}\n          />\n          <HandwritingFontPicker\n            fonts={fonts}\n            value={draft.style.font}\n            onChange={(font) => onChangeStyle({ font })}\n            label={labels?.fontLabel}\n          />\n        </div>\n\n        <div className=\"flex items-center gap-1\">\n          {onClose ? (\n            <Button\n              type=\"button\"\n              size=\"icon-sm\"\n              variant=\"ghost\"\n              aria-label={labels?.closeComposer ?? \"Close\"}\n              onClick={onClose}\n              className=\"text-white/55 hover:bg-white/10 hover:text-white\"\n            >\n              <X aria-hidden />\n            </Button>\n          ) : null}\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            onClick={onPost}\n            disabled={!canPost}\n            aria-label={labels?.post ?? \"Post\"}\n          >\n            <SendHorizontal aria-hidden />\n            {labels?.post ?? \"Post\"}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/note-composer.tsx"
    },
    {
      "path": "src/registry/components/data/blackboard/parts/unread-count.tsx",
      "content": "import type { CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { UNREAD_RED } from \"../lib/palette\";\n\nexport interface UnreadCountProps {\n  count: number;\n  /** Aria label builder, e.g. (n) => `${n} unread notes`. */\n  ariaLabel?: (n: number) => string;\n  /** Handwriting font var to render the number in. Default chalk-red Kalam. */\n  fontVar?: string;\n  onClick?: () => void;\n  className?: string;\n}\n\n/**\n * The handwritten red unread marker — a chalk-red number, slightly rotated like\n * a teacher's tally. Pure + context-free. Renders nothing when `count` ≤ 0.\n */\nexport function UnreadCount({\n  count,\n  ariaLabel,\n  fontVar = \"--bb-font-kalam\",\n  onClick,\n  className,\n}: UnreadCountProps) {\n  if (count <= 0) return null;\n  const label = ariaLabel ? ariaLabel(count) : `${count} unread`;\n  const style: CSSProperties = {\n    color: UNREAD_RED,\n    fontFamily: `var(${fontVar})`,\n    textShadow: \"0 0.5px 0.6px rgba(255,255,255,0.08)\",\n  };\n\n  const content = (\n    <span\n      className=\"select-none text-2xl font-bold leading-none -rotate-6 tabular-nums\"\n      style={style}\n    >\n      {count > 99 ? \"99+\" : count}\n    </span>\n  );\n\n  if (onClick) {\n    return (\n      <button\n        type=\"button\"\n        onClick={onClick}\n        aria-label={label}\n        className={cn(\n          \"inline-flex cursor-pointer items-center rounded-md p-1 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]\",\n          className,\n        )}\n      >\n        {content}\n      </button>\n    );\n  }\n  return (\n    <span role=\"status\" aria-label={label} className={cn(\"inline-flex items-center p-1\", className)}>\n      {content}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blackboard/parts/unread-count.tsx"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:block"
}