{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "culture-log",
  "type": "registry:component",
  "title": "Culture log",
  "description": "A culture and sample record with supplied medium, conditions, chronological observations and host-owned recording actions.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/culture-log.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Button } from \"./button\";\n\nexport interface CultureCondition { id: string; label: string; value: React.ReactNode }\nexport interface CultureAction { id: string; label: string; disabled?: boolean }\nexport interface CultureObservation {\n  id: string;\n  label: string;\n  /** An offset-bearing timestamp. Missing or invalid times remain undated and sort last. */\n  dateTime: string | null;\n  timeLabel?: string;\n  status?: string | null;\n  notes?: React.ReactNode;\n}\nexport interface CultureLogProps extends React.HTMLAttributes<HTMLDivElement> {\n  label: string;\n  cultureId: string;\n  sampleId: string;\n  observations: readonly CultureObservation[];\n  medium?: React.ReactNode;\n  status?: string | null;\n  conditions?: readonly CultureCondition[];\n  actions?: readonly CultureAction[];\n  /** Requests a host recording action without changing any supplied observation or status. */\n  onAction?: (actionId: string) => void;\n  pending?: boolean;\n  order?: \"oldest\" | \"newest\";\n}\nconst styles = stylex.create({\n  root: { display: \"grid\", gap: \"1rem\", minWidth: 0, color: vlak.ink, lineHeight: 1.45, overflowWrap: \"anywhere\" },\n  head: { display: \"flex\", flexWrap: \"wrap\", justifyContent: \"space-between\", alignItems: \"baseline\", gap: \"0.5rem 1rem\" },\n  title: { margin: 0, fontSize: \"0.875rem\", fontWeight: 600 },\n  copy: { margin: 0, fontSize: \"0.75rem\", color: vlak.gray, maxWidth: \"66ch\" },\n  metadata: { display: \"grid\", gridTemplateColumns: { default: \"repeat(auto-fit, minmax(min(100%, 8rem), 1fr))\", [mq.phone]: \"minmax(0, 1fr)\" }, gap: \"0.75rem 1rem\", paddingBlock: \"1rem\", margin: 0, borderBlockWidth: vlak.hairline, borderBlockStyle: \"solid\", borderBlockColor: vlak.divider },\n  value: { margin: \"0.25rem 0 0\", fontSize: \"0.875rem\", fontVariantNumeric: \"tabular-nums\" },\n  list: { listStyleType: \"none\", margin: 0, padding: 0 },\n  observation: { display: \"grid\", gridTemplateColumns: { default: \"minmax(6rem, 1fr) minmax(0, 3fr)\", [mq.phone]: \"minmax(0, 1fr)\" }, gap: \"0.5rem 1rem\", paddingBlock: \"0.875rem\", borderBottomWidth: vlak.hairline, borderBottomStyle: \"solid\", borderBottomColor: vlak.divider },\n  time: { fontSize: \"0.75rem\", color: vlak.gray, fontVariantNumeric: \"tabular-nums\" },\n  content: { display: \"grid\", gap: \"0.375rem\", minWidth: 0 },\n  observationLabel: { margin: 0, fontSize: \"0.875rem\", fontWeight: 500 },\n  notes: { fontSize: \"0.875rem\", maxWidth: \"66ch\" },\n  actions: { display: \"flex\", flexWrap: \"wrap\", gap: \"0.5rem\" },\n  action: { minWidth: vlak.hit, minHeight: vlak.hit, width: \"auto\" },\n});\nfunction timestamp(value: string | null) {\n  if (value == null) return null;\n  const parts = /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:\\d{2})$/.exec(value);\n  if (!parts) return null;\n  const [, yearText, monthText, dayText, hourText, minuteText, secondText] = parts;\n  const year = Number(yearText), month = Number(monthText), day = Number(dayText);\n  const lastDay = month === 2 ? (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31;\n  if (month < 1 || month > 12 || day < 1 || day > lastDay || Number(hourText) > 23 || Number(minuteText) > 59 || Number(secondText ?? 0) > 59) return null;\n  const result = Date.parse(value);\n  return Number.isFinite(result) ? result : null;\n}\nconst supplied = (value: React.ReactNode) => value == null || typeof value === \"string\" && !value.trim() ? \"Not supplied\" : typeof value === \"number\" && !Number.isFinite(value) ? \"Unavailable\" : value;\n/** A chronological notebook of supplied culture observations, without protocol recommendations. */\nexport const CultureLog = React.forwardRef<HTMLDivElement, CultureLogProps>(function CultureLog({ label, cultureId, sampleId, observations, medium, status, conditions = [], actions = [], onAction, pending = false, order = \"oldest\", className, style, children, ...props }, ref) {\n  const id = React.useId();\n  const valid = observations.length <= 256 && conditions.length <= 32 && actions.length <= 16 && [observations, conditions, actions].every(records => records.every(record => record.id.trim()) && new Set(records.map(record => record.id)).size === records.length);\n  const sorted = valid ? observations.map((observation, index) => ({ observation, index, time: timestamp(observation.dateTime) })).sort((a, b) => a.time == null ? b.time == null ? a.index - b.index : 1 : b.time == null ? -1 : (order === \"newest\" ? b.time - a.time : a.time - b.time) || a.index - b.index) : [];\n  const root = rs([\"rs-culture-log\", className], styles.root);\n  const head = rs([\"rs-culture-log-head\"], styles.head);\n  const title = rs([\"rs-culture-log-title\"], styles.title);\n  const copy = rs([\"rs-culture-log-copy\"], styles.copy);\n  const metadata = rs([\"rs-culture-log-metadata\"], styles.metadata);\n  const valueStyle = rs([\"rs-culture-log-value\"], styles.value);\n  const list = rs([\"rs-culture-log-list\"], styles.list);\n  const observationStyle = rs([\"rs-culture-log-observation\"], styles.observation);\n  const timeStyle = rs([\"rs-culture-log-time\"], styles.time);\n  const content = rs([\"rs-culture-log-content\"], styles.content);\n  const observationLabel = rs([\"rs-culture-log-observation-label\"], styles.observationLabel);\n  const notes = rs([\"rs-culture-log-notes\"], styles.notes);\n  const actionsStyle = rs([\"rs-culture-log-actions\"], styles.actions);\n  const actionStyle = rs([\"rs-culture-log-action\"], styles.action);\n  return <div ref={ref} role=\"group\" aria-labelledby={`${id}-label`} {...props} className={root.className} style={{ ...root.style, ...style }}>\n    <div {...head}><p {...title} id={`${id}-label`}>{label}</p><span {...copy} id={`${id}-status`}>{status?.trim() || \"Status not supplied\"}</span></div>\n    <dl {...metadata}><div><dt {...copy}>Culture</dt><dd {...valueStyle}>{supplied(cultureId)}</dd></div><div><dt {...copy}>Sample</dt><dd {...valueStyle}>{supplied(sampleId)}</dd></div><div><dt {...copy}>Medium</dt><dd {...valueStyle}>{supplied(medium)}</dd></div>{valid && conditions.map(condition => <div key={condition.id}><dt {...copy}>{condition.label.trim() || \"Condition\"}</dt><dd {...valueStyle}>{supplied(condition.value)}</dd></div>)}</dl>\n    {!valid ? <p {...copy}>Culture records unavailable: supply unique identifiers, at most 256 observations, 32 conditions and 16 actions</p> : <>\n      {sorted.length === 0 ? <p {...copy}>No observations supplied</p> : <><p {...copy}>{order === \"oldest\" ? \"Oldest\" : \"Newest\"} dated observation first; undated records follow</p><ol {...list} aria-label={`${label}, observations`}>{sorted.map(({ observation, time }) => <li key={observation.id} {...observationStyle}>\n        <time {...timeStyle} dateTime={time == null ? undefined : observation.dateTime!}>{time == null ? observation.dateTime == null ? \"Time not supplied\" : \"Timestamp unavailable\" : observation.timeLabel?.trim() || observation.dateTime}</time>\n        <div {...content}><div {...head}><p {...observationLabel}>{observation.label.trim() || \"Observation label not supplied\"}</p><span {...copy}>{observation.status?.trim() || \"Observation status not supplied\"}</span></div>{observation.notes != null && <div {...notes}>{observation.notes}</div>}</div>\n      </li>)}</ol></>}\n      <p {...copy} role=\"status\">{pending ? \"Record update pending; supplied status is unchanged\" : null}</p>\n      {onAction && actions.length > 0 && <div {...actionsStyle}>{actions.map(action => <Button key={action.id} {...actionStyle} variant=\"ghost\" disabled={pending || action.disabled} aria-label={`${action.label}: ${cultureId.trim() || label}`} aria-describedby={`${id}-status`} onClick={() => onAction(action.id)}>{action.label}</Button>)}</div>}\n    </>}{children}\n  </div>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/culture-log.tsx"
    },
    {
      "path": "vlak/styles/culture-log.css",
      "content": "/* ── culture-log: generated from packages/react/src/components/culture-log.tsx ── */\n.rs-culture-log{display:grid;gap:1rem;min-width:0;color:var(--text);line-height:1.45;overflow-wrap:anywhere}\n.rs-culture-log-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:0.5rem 1rem}\n.rs-culture-log-title{margin:0;font-size:0.875rem;font-weight:600}\n.rs-culture-log-copy{margin:0;font-size:0.75rem;color:var(--text-secondary);max-width:66ch}\n.rs-culture-log-metadata{display:grid;grid-template-columns:repeat(auto-fit, minmax(min(100%, 8rem), 1fr));gap:0.75rem 1rem;padding-block:1rem;margin:0;border-block-width:1px;border-block-style:solid;border-block-color:var(--divider)}\n@media (max-width: 640px){.rs-culture-log-metadata{grid-template-columns:minmax(0, 1fr)}}\n.rs-culture-log-value{margin:0.25rem 0 0;font-size:0.875rem;font-variant-numeric:tabular-nums}\n.rs-culture-log-list{list-style-type:none;margin:0;padding:0}\n.rs-culture-log-observation{display:grid;grid-template-columns:minmax(6rem, 1fr) minmax(0, 3fr);gap:0.5rem 1rem;padding-block:0.875rem;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:var(--divider)}\n@media (max-width: 640px){.rs-culture-log-observation{grid-template-columns:minmax(0, 1fr)}}\n.rs-culture-log-time{font-size:0.75rem;color:var(--text-secondary);font-variant-numeric:tabular-nums}\n.rs-culture-log-content{display:grid;gap:0.375rem;min-width:0}\n.rs-culture-log-observation-label{margin:0;font-size:0.875rem;font-weight:500}\n.rs-culture-log-notes{font-size:0.875rem;max-width:66ch}\n.rs-culture-log-actions{display:flex;flex-wrap:wrap;gap:0.5rem}\n.rs-culture-log-action{min-width:var(--hit);min-height:var(--hit);width:auto}\n",
      "type": "registry:file",
      "target": "styles/vlak/culture-log.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "microbiology",
      "classes": [
        "rs-culture-log",
        "rs-culture-log-head",
        "rs-culture-log-title",
        "rs-culture-log-copy",
        "rs-culture-log-metadata",
        "rs-culture-log-value",
        "rs-culture-log-list",
        "rs-culture-log-observation",
        "rs-culture-log-time",
        "rs-culture-log-content",
        "rs-culture-log-observation-label",
        "rs-culture-log-notes",
        "rs-culture-log-actions",
        "rs-culture-log-action"
      ],
      "snippet": "<div class=\"rs-culture-log\" role=\"group\" aria-label=\"Culture observations\"><div class=\"rs-culture-log-head\"><p class=\"rs-culture-log-title\">Culture observations</p><span class=\"rs-culture-log-copy\">Awaiting record review</span></div><dl class=\"rs-culture-log-metadata\"><div><dt class=\"rs-culture-log-copy\">Culture</dt><dd class=\"rs-culture-log-value\">C-042</dd></div><div><dt class=\"rs-culture-log-copy\">Sample</dt><dd class=\"rs-culture-log-value\">S-042</dd></div><div><dt class=\"rs-culture-log-copy\">Medium</dt><dd class=\"rs-culture-log-value\">Agar medium A</dd></div></dl><ol class=\"rs-culture-log-list\"><li class=\"rs-culture-log-observation\"><time class=\"rs-culture-log-time\" datetime=\"2026-09-07T09:00:00+02:00\">7 September, 09:00</time><div class=\"rs-culture-log-content\"><p class=\"rs-culture-log-observation-label\">Sample record received</p></div></li></ol></div>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "vlak-lib"
      ],
      "aliases": [
        "Culture notebook",
        "Culture observations",
        "Microbiology log",
        "Culture record",
        "Sample culture history"
      ],
      "example": "import { useState } from \"react\";\nimport { CultureLog } from \"@noorddev/vlak-react\";\n\nfunction CultureRecord() {\n  const [reviewed, setReviewed] = useState(false);\n  return <CultureLog label=\"Culture observations\" cultureId=\"C-042\" sampleId=\"S-042\" medium=\"Agar medium A\" status={reviewed ? \"Record review noted\" : \"Awaiting record review\"} conditions={[{ id: \"batch\", label: \"Medium batch\", value: \"M-17\" }]} observations={[\n    { id: \"received\", label: \"Sample record received\", dateTime: \"2026-09-07T09:00:00+02:00\", timeLabel: \"7 September, 09:00\", status: \"Recorded\" },\n    { id: \"image\", label: \"Plate image attached\", dateTime: \"2026-09-07T10:30:00+02:00\", timeLabel: \"7 September, 10:30\", status: \"Recorded\", notes: \"Image and source annotations attached\" },\n  ]} actions={reviewed ? [] : [{ id: \"review\", label: \"Record review\" }]} onAction={() => setReviewed(true)} />;\n}",
      "usage": {
        "use": [
          "Review culture identity, sample identity, medium and conditions as supplied by the host.",
          "Provide offset-bearing timestamps for chronological ordering. Equal timestamps retain their supplied order; missing and invalid timestamps sort after dated observations.",
          "Supply actions and onAction to request recording changes. Update status or observations only when the host has accepted the change; pending disables actions without changing recorded state.",
          "Use oldest or newest ordering for at most 256 observations, 32 conditions and 16 actions with unique non-empty identifiers."
        ],
        "avoid": [
          "Inferring organism identity, contamination, growth interpretation or culture success from a status or observation.",
          "Generating a culture recipe, an incubation recommendation or a procedure from the displayed conditions.",
          "Treating a requested recording action as confirmation that a laboratory procedure occurred."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Moves between supplied links and enabled recording actions."
        },
        {
          "keys": "Enter, Space",
          "does": "Requests the focused action through onAction without submitting a surrounding form."
        }
      ],
      "a11y": [
        "A named group contains a description list for metadata and an ordered list of observations. Every valid dated observation has a native time element.",
        "Missing times and malformed dates remain explicitly undated. Dates without an offset and impossible calendar dates are not silently reinterpreted.",
        "Zero-valued conditions remain visible; empty values and non-finite numeric conditions have explicit availability labels. Supplied statuses remain unchanged during pending requests.",
        "Recording actions have 44px targets and visible focus rings. Their names include the culture identity and their descriptions reference the supplied status.",
        "Oversized or ambiguous record collections have a visible explanation instead of a partial timeline. The div ref, native attributes, className and style pass through."
      ]
    }
  }
}
