{
  "$schema": "https://konspec.internal/ui/registry.schema.json",
  "name": "Konspec UI",
  "version": "0.1.0",
  "purpose": "Copy-paste HTML components for internal data applications. Light theme only.",
  "generated_by": "node tools/build.js",
  "read_first": "llms.txt",
  "stack": {
    "css": "Tailwind CSS v4 — utility classes only. No component CSS, no @apply, no custom colour tokens.",
    "js": "Alpine.js 3 for local UI state, plus two official plugins — @alpinejs/collapse for animated panels and @alpinejs/focus for dialog focus traps. Both load before Alpine core. State lives on the component root, never in a global store.",
    "server": "htmx for partial swaps. Alpine does not fetch.",
    "charts": "Chart.js 4 via CDN, pinned, and only on pages that plot something. Colours are read out of the DOM at init, never written as hex: series from the HTML legend swatches, grid from the card border.",
    "icons": "Lucide via CDN — <i data-lucide=\"name\"></i>, hydrated by lucide.createIcons().",
    "build": "None. Snippets are plain HTML and paste into Django, Flask or a static file unchanged."
  },
  "rules": [
    "Stock Tailwind only — No custom CSS, no @theme colours, no arbitrary hex. Arbitrary values are for one-off sizes, never for colour.",
    "Light theme only — There is no dark mode and none is planned. A second theme doubles the review surface for no benefit in an office tool.",
    "Never bind Alpine on a Lucide icon — createIcons() replaces the <i> with an <svg>, destroying any :class on it. Bind on a wrapping span instead.",
    "Never add cursor-pointer — Tailwind v4 preflight drops it from buttons. One base rule fixes every element; 300 utility classes do not.",
    "Colour means data state — Red, amber and green describe what a record is doing. Never decoration, never a brand accent. They appear as a dot or an icon, never as a field of colour behind text.",
    "The accent is graphite — zinc-700. Interactive text is zinc-900 plus an underline, not a colour.",
    "Alerts are neutral — White card, zinc border, colour only in the icon. A full-width red field shouts louder than the overdue rows it describes.",
    "Every tinted shape carries its ring — A tinted shape is bg-zinc-200 with ring-1 ring-inset ring-zinc-300 — pills, chips, avatars, icon wells, all of them. A tinted surface is bg-zinc-100 — the page, selected rows, active nav, table headers. Give a shape the surface fill and it measures 1.00 contrast against the surface it sits on, which is not low contrast but the identical colour, and it disappears. One step of fill separates them and the ring holds the edge. Solid shapes need no ring, and it is ring rather than border so adding it reflows nothing.",
    "Status colour lives in the dot — Every status pill is the same graphite shape — bg-zinc-200, ring-zinc-300, zinc-700 text, the same fill an avatar takes. What separates Open from Overdue is a 6px dot. A column of tinted pills reads as a traffic light and stops meaning anything by the twelfth row; a column of identical pills with one red dot in it reads at a glance. This is the alert rule — colour only in the marker — applied to a pill.",
    "One shade per meaning — Info is zinc-400, success emerald-600, warning amber-700, danger red-600 — and a warning is amber-700 in a component and amber-500 only when it is a dot, because a 1.5px stroke and a 6px disc need different weights of the same colour. Nothing takes a -50 tint or a -200 ring: a coloured icon well is still bg-zinc-200 with ring-zinc-300. Pick the shade from the token table, never by eye.",
    "Survive 390px — Tables become stacked cards. Nothing scrolls sideways on a phone, with one exception: a carousel, where the sideways scroll is the component itself, and which snaps and peeks so it never looks like a layout that broke.",
    "No template x-for inside svg — It is parsed in the SVG namespace and has no .content. Use divs, or a conic-gradient for donuts.",
    "x-cloak on anything hidden at first paint — Alpine boots after the HTML renders. Without it, every dropdown flashes on load.",
    "Every text size carries its leading — text-[13px] compiles to font-size alone — arbitrary sizes emit no line-height, so the element silently inherits whatever an ancestor left. Always write the slash form, text-[13px]/5. Sizes come from the seven steps; only prose may take a looser leading than the default pairing.",
    "Guard the Lucide re-hydration loop — createIcons() leaves data-lucide on the <svg> it generates, so re-running it on every DOM mutation re-renders every icon forever. Guard on document.querySelector(\"[data-lucide]:not(svg)\") or animations stutter while the DOM never settles."
  ],
  "semantic_colours": {
    "note": "One shade per meaning, everywhere. A mark you read takes the text class; a dot or bar takes the fill class. No -50 tints and no coloured rings — a coloured icon well is still bg-zinc-200 ring-zinc-300.",
    "tone": {
      "info": {
        "icon": "info",
        "text": "text-zinc-500",
        "use": "Neutral notice. Grey, because nothing is wrong."
      },
      "success": {
        "icon": "check-circle-2",
        "text": "text-emerald-600",
        "use": "Finished, posted, sent. Same green as the Closed dot."
      },
      "warning": {
        "icon": "alert-triangle",
        "text": "text-amber-700",
        "use": "Waiting, expiring, over a limit. 700 for marks you read — a 1.5px amber-500 stroke on white is illegible."
      },
      "danger": {
        "icon": "alert-circle",
        "text": "text-red-600",
        "use": "Overdue, failed, destructive. Same red as the Overdue dot."
      }
    },
    "fill": {
      "success": {
        "fill": "bg-emerald-600",
        "use": "Status dot, presence dot, healthy indicator."
      },
      "warning": {
        "fill": "bg-amber-500",
        "use": "Status dot only. There is no amber fill larger than 6px."
      },
      "danger": {
        "fill": "bg-red-600",
        "use": "Status dot, notification dot, an overdue progress bar, the destructive button — hover:bg-red-700."
      }
    }
  },
  "status_colours": {
    "open": {
      "pill": "bg-zinc-200 text-zinc-700 ring-1 ring-inset ring-zinc-300",
      "dot": "bg-zinc-500",
      "meaning": "The ordinary case"
    },
    "approved": {
      "pill": "bg-zinc-200 text-zinc-700 ring-1 ring-inset ring-zinc-300",
      "dot": "bg-amber-500",
      "meaning": "Waiting on someone"
    },
    "overdue": {
      "pill": "bg-zinc-200 text-zinc-700 ring-1 ring-inset ring-zinc-300",
      "dot": "bg-red-600",
      "meaning": "The alarm state"
    },
    "closed": {
      "pill": "bg-zinc-200 text-zinc-700 ring-1 ring-inset ring-zinc-300",
      "dot": "bg-emerald-600",
      "meaning": "Finished"
    },
    "draft": {
      "pill": "bg-zinc-200 text-zinc-600 ring-1 ring-inset ring-zinc-300",
      "dot": "bg-zinc-400",
      "meaning": "Not real yet"
    }
  },
  "typography": {
    "families": {
      "sans": "Inter",
      "mono": "JetBrains Mono"
    },
    "hosting": "self-hosted, assets/fonts.css — no font CDN at runtime",
    "note": "An arbitrary font size emits no line-height. Always write the slash form: text-[13px]/5.",
    "scale": [
      {
        "token": "text-[11px]/4",
        "use": "uppercase labels, counts, badge text"
      },
      {
        "token": "text-[12px]/4",
        "use": "help text, timestamps, secondary lines"
      },
      {
        "token": "text-[13px]/5",
        "use": "buttons, table cells, default component text"
      },
      {
        "token": "text-[14px]/5",
        "use": "body copy, inputs, prose"
      },
      {
        "token": "text-[16px]/6",
        "use": "card, dialog and panel titles"
      },
      {
        "token": "text-[20px]/7",
        "use": "section headings, error page titles"
      },
      {
        "token": "text-[24px]/7",
        "use": "page titles, KPI figures"
      }
    ],
    "weight": [
      {
        "token": "font-normal",
        "value": "400",
        "use": "body copy, table cells, anything you read as a sentence"
      },
      {
        "token": "font-medium",
        "value": "500",
        "use": "anything that labels or acts — buttons, table headers, alert titles, nav items"
      },
      {
        "token": "font-semibold",
        "value": "600",
        "use": "headings, KPI figures, panel titles"
      }
    ],
    "tracking": [
      {
        "token": "tracking-wider",
        "use": "11px uppercase labels only"
      },
      {
        "token": "tracking-tight",
        "use": "headings at 20px and above"
      },
      {
        "token": "normal",
        "use": "everywhere else. No arbitrary em values."
      }
    ]
  },
  "categories": [
    {
      "id": "actions",
      "label": "Actions"
    },
    {
      "id": "forms",
      "label": "Forms"
    },
    {
      "id": "data",
      "label": "Data display"
    },
    {
      "id": "feedback",
      "label": "Feedback"
    },
    {
      "id": "navigation",
      "label": "Navigation"
    },
    {
      "id": "layout",
      "label": "Layout"
    }
  ],
  "components": [
    {
      "id": "button",
      "name": "Button",
      "category": "actions",
      "description": "The standard action control. Exactly one primary button per view; everything else is secondary, ghost or a link.",
      "when_to_use": "Any action the user takes on this page. Anything that goes to another page is an anchor, not a button.",
      "rules": [
        "Tailwind v4 preflight drops cursor:pointer from <button>. One base rule in the page stylesheet restores it for every interactive element — never add cursor-pointer to a button.",
        "Hover is always one step deeper than the button's own resting fill: zinc-700 to zinc-800, white to zinc-100, red-600 to red-700. Ghost has no fill of its own, so it hovers to zinc-200 — zinc-100 would be invisible on the zinc-100 page background.",
        "Height is set with h-*, never left to padding, and every variant carries a border — border-transparent on the ones with no visible edge. Padding-derived height cannot match an icon-only button: measured, a bordered secondary came out at 38 against a 36 primary, and a size-9 icon button at 36 against a 38 label. Fixing the height fixes both at once and px-* then controls width alone.",
        "Danger is reserved for destructive actions that cannot be undone. A red Save is a lie about the stakes.",
        "One primary per view. Three solid buttons on a screen have told the user nothing about which one matters.",
        "A submit button is disabled while its request is in flight, and the label does not change width when it does — otherwise the row reflows under the cursor mid-click."
      ],
      "anatomy": [
        {
          "part": "Label",
          "description": "A verb and its object. \"Approve order\", not \"OK\" — someone reading only the buttons should know what each one does."
        },
        {
          "part": "Icon",
          "description": "Optional, left of the label at size-4. It clarifies the verb; it never replaces it except in an icon-only button."
        },
        {
          "part": "Surface",
          "description": "What the variant actually is: solid zinc-700 for primary, white with a zinc-200 border for secondary, red-600 for danger, nothing at all for ghost."
        },
        {
          "part": "Hit area",
          "description": "h-7 small, h-9 medium, h-11 large — 28, 36 and 44px. Icon-only is the matching square: size-7, size-9, or size-8 in a dense toolbar beside h-8 labels."
        },
        {
          "part": "Busy state",
          "description": "A spinning loader in place of the icon while the request is in flight, with the label in the present participle and the width held."
        }
      ],
      "behaviour": [
        "One primary button per view. A screen with three solid buttons has told the user nothing about which one matters.",
        "A button that submits stays disabled while the request is in flight, or a double click posts twice.",
        "The label does not change width between idle and busy states, so the row does not reflow under the cursor.",
        "Disabled is a real disabled attribute, never a class that only looks disabled — the second kind still fires its handler.",
        "Anything that navigates is an <a>. A button that changes the URL breaks middle-click, open-in-new-tab and the browser's own history.",
        "Below sm, buttons in a form or dialog footer go full width and stack. Two 36px buttons side by side on a 390px screen are a thumb-sized problem.",
        "Every button in a row is the same height, which is why height is declared rather than inferred. Mixed heights in a toolbar read as a rendering fault, not as hierarchy."
      ],
      "accessibility": [
        "An icon-only button carries aria-label, because its only content is decorative.",
        "The busy state sets aria-busy=\"true\" and keeps the disabled attribute, so the wait is announced and not merely drawn.",
        "Disabled buttons are skipped by Tab and announced as unavailable, which is why the attribute matters more than the styling.",
        "The browser's own focus ring is left in place on every variant. Nothing here sets outline:none, and the ghost variant especially depends on it — there is no border to thicken.",
        "A button that only opens something carries aria-expanded and aria-haspopup, so its state is known before it is pressed.",
        "type=\"button\" is explicit on every button inside a <form>, because the default is submit and a stray toolbar button will post the form."
      ],
      "related": [
        "button-group",
        "dropdown",
        "alert-dialog"
      ],
      "variants": [
        {
          "id": "variants",
          "name": "Variants",
          "html": "<!-- Five, and no others. Primary is the one thing this screen is for; secondary\n     is everything else that acts; ghost is for actions dense enough that a\n     border each would draw a grid; danger cannot be undone; link navigates. -->\n<div class=\"flex flex-wrap items-center gap-3\">\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Primary</button>\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">Secondary</button>\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent px-4 text-[13px]/5 font-medium text-zinc-900 hover:bg-zinc-200\">Ghost</button>\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-red-600 px-4 text-[13px]/5 font-medium text-white hover:bg-red-700\">Danger</button>\n  <a href=\"#\" class=\"text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">Link</a>\n</div>"
        },
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- Three, matched to density rather than to importance. Small belongs in a\n     table row or a dense toolbar; large is for the one button on a sign-in card\n     or the primary action of a full-page form. Everything else is medium. -->\n<div class=\"flex flex-wrap items-center gap-3\">\n  <button type=\"button\" class=\"inline-flex h-7 items-center rounded-md border border-transparent bg-zinc-700 px-2.5 text-[12px]/4 font-medium text-white hover:bg-zinc-800\">Small</button>\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Medium</button>\n  <button type=\"button\" class=\"inline-flex h-11 items-center rounded-lg border border-transparent bg-zinc-700 px-5 text-[14px]/5 font-medium text-white hover:bg-zinc-800\">Large</button>\n</div>"
        },
        {
          "id": "icons",
          "name": "With icons",
          "html": "<!-- An icon sits left of the label and clarifies the verb. A chevron is the\n     exception and sits right, because it points at what is about to open.\n\n     Icon-only buttons are square so the hit area stays square, and they carry\n     aria-label — the icon is decorative and contributes nothing to the name. -->\n<div class=\"flex flex-wrap items-center gap-3\">\n  <button type=\"button\" class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n    <i data-lucide=\"plus\" class=\"size-4\"></i>New order\n  </button>\n  <button type=\"button\" class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"download\" class=\"size-4 text-zinc-600\"></i>Export\n  </button>\n  <button type=\"button\" aria-haspopup=\"menu\" class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    Columns<i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-600\"></i>\n  </button>\n  <button type=\"button\" aria-label=\"Edit order\" class=\"inline-flex size-9 items-center justify-center rounded-lg border border-zinc-200 bg-white hover:bg-zinc-100\">\n    <i data-lucide=\"pencil\" class=\"size-4\"></i>\n  </button>\n  <button type=\"button\" aria-label=\"More actions\" class=\"inline-flex size-9 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n    <i data-lucide=\"ellipsis\" class=\"size-4\"></i>\n  </button>\n</div>"
        },
        {
          "id": "states",
          "name": "Idle, busy, disabled",
          "html": "<!-- The busy button is the same width as the idle one. \"Approve\" and\n     \"Approving…\" are different lengths, so the label is held at its widest with\n     a grid overlay: both strings occupy the same cell, only one is visible, and\n     the cell is as wide as the longer of the two. Without that the row shifts\n     under the cursor the instant it is clicked.\n\n     Busy keeps disabled as well as aria-busy — aria-busy announces the wait,\n     disabled is what actually stops the second click. -->\n<div class=\"flex flex-wrap items-center gap-3\" x-data=\"{ busy: false }\">\n  <button type=\"button\" @click=\"busy = true; setTimeout(() => busy = false, 2200)\"\n          :disabled=\"busy\" :aria-busy=\"busy\"\n          class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800 disabled:hover:bg-zinc-700\">\n    <i x-show=\"!busy\" data-lucide=\"check\" class=\"size-4\"></i>\n    <i x-show=\"busy\" x-cloak data-lucide=\"loader-circle\" class=\"size-4 animate-spin\"></i>\n    <span class=\"grid\">\n      <span class=\"col-start-1 row-start-1\" :class=\"busy && 'invisible'\">Approve</span>\n      <span class=\"col-start-1 row-start-1\" :class=\"!busy && 'invisible'\" aria-hidden=\"true\">Approving…</span>\n    </span>\n  </button>\n\n  <button type=\"button\" disabled class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-200 px-4 text-[13px]/5 font-medium text-zinc-400\">Disabled</button>\n  <button type=\"button\" disabled class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium text-zinc-400\">Disabled secondary</button>\n</div>"
        },
        {
          "id": "link",
          "name": "Navigating, not acting",
          "html": "<!-- An anchor that looks like a button. If it changes the URL it must be an\n     <a> — a button that calls location.href breaks middle-click, open in new\n     tab, copy link address and the back button, all silently.\n\n     inline-flex rather than flex, because an anchor is inline and a bare flex\n     would stretch it across the row. -->\n<div class=\"flex flex-wrap items-center gap-3\">\n  <a href=\"#\" class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n    <i data-lucide=\"plus\" class=\"size-4\"></i>New purchase order\n  </a>\n  <a href=\"#\" class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    Open in ledger<i data-lucide=\"arrow-up-right\" class=\"size-3.5 text-zinc-600\"></i>\n  </a>\n</div>"
        },
        {
          "id": "full-width",
          "name": "Full width on a phone",
          "html": "<!-- A form or dialog footer. Below sm the buttons stack and fill the width;\n     from sm they sit inline and right-aligned. flex-col-reverse is deliberate:\n     the primary is last in the DOM so it is last in the Tab order, but first on\n     screen once stacked, which is where a thumb lands. -->\n<div class=\"max-w-md rounded-xl border border-zinc-200 bg-white p-5\">\n  <p class=\"text-[14px]/5\">Approve PO-24-1187 for ₹4,82,000?</p>\n  <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Gujarat Polymers Ltd · 6 lines</p>\n\n  <div class=\"mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n    <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n    <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n  </div>\n</div>"
        },
        {
          "id": "toolbar",
          "name": "In a page header",
          "html": "<!-- Where the one-primary rule earns its keep. Four actions, one solid. The\n     primary sits last, nearest the right edge and the thumb, and matches the\n     order a dialog footer uses so the two never contradict each other. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white px-5 py-4\">\n  <div class=\"flex flex-wrap items-start justify-between gap-3\">\n    <div>\n      <h1 class=\"text-[20px]/7 font-semibold tracking-tight tabular-nums\">PO-24-1187</h1>\n      <p class=\"mt-0.5 text-[13px]/5 text-zinc-600\">Gujarat Polymers Ltd · raised 14 Aug 2026</p>\n    </div>\n    <div class=\"flex flex-wrap items-center gap-2\">\n      <button type=\"button\" class=\"inline-flex h-8 items-center rounded-lg border border-transparent px-3 text-[13px]/5 font-medium text-zinc-900 hover:bg-zinc-200\">Cancel order</button>\n      <button type=\"button\" aria-label=\"Print\" class=\"inline-flex size-8 items-center justify-center rounded-lg border border-zinc-200 bg-white hover:bg-zinc-100\">\n        <i data-lucide=\"printer\" class=\"size-4\"></i>\n      </button>\n      <button type=\"button\" class=\"inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n        <i data-lucide=\"download\" class=\"size-4 text-zinc-600\"></i>Export\n      </button>\n      <button type=\"button\" class=\"inline-flex h-8 items-center rounded-lg border border-transparent bg-zinc-700 px-3 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form submits",
          "html": "<!-- One form, two outcomes. Both buttons submit; name and value tell the view\n     which was pressed, so there is no second form and no JavaScript.\n\n     # views.py\n     if 'action' in request.POST:\n         if request.POST['action'] == 'approve':  order.approve(request.user)\n         elif request.POST['action'] == 'draft':  order.save_draft()\n\n     type=\"submit\" is explicit even though it is the default, because the\n     surrounding toolbar buttons all carry type=\"button\" and the difference is\n     the whole point. -->\n<form method=\"post\" class=\"max-w-md rounded-xl border border-zinc-200 bg-white p-5\">\n  {% csrf_token %}\n  <label for=\"id_remarks\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Approval remarks</label>\n  <textarea name=\"remarks\" id=\"id_remarks\" rows=\"3\"\n            class=\"w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[14px]/5 outline-none focus:border-zinc-700 focus:ring-3 focus:ring-zinc-700/15\"></textarea>\n\n  <div class=\"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n    <button type=\"submit\" name=\"action\" value=\"draft\"\n            class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">Save as draft</button>\n    <button type=\"submit\" name=\"action\" value=\"approve\"\n            class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n  </div>\n</form>"
        }
      ]
    },
    {
      "id": "button-group",
      "name": "Button group",
      "category": "actions",
      "description": "Several controls joined into one object. Two shapes only: a tinted track for choosing one of a set, and a bordered strip for actions that belong together.",
      "when_to_use": "When the controls are genuinely related — one date range, one set of row actions. Unrelated buttons sitting near each other are a toolbar, not a group, and get normal gaps.",
      "rules": [
        "Two shapes and no third. A tinted track with a white active pill means \"pick one of these\"; a bordered strip with dividers means \"these do different things\". Mixing them makes a set of actions look like a choice.",
        "A group where only one can be active is a radio group, not a row of buttons. role=\"radiogroup\", role=\"radio\", aria-checked, arrow keys and a roving tabindex — otherwise the keyboard tabs through every option one at a time and nothing announces which is chosen.",
        "Never wrap a group in overflow-hidden to round its corners. It clips the focus ring off the first and last buttons. Round the end buttons instead.",
        "A group is one object, so it never wraps. Below sm it becomes a select or a menu, not two rows of joined buttons with the join broken.",
        "The divider between attached buttons is a single border-l on every button but the first. Putting a border on all four sides doubles every internal rule to 2px.",
        "Members declare their height with h-*, matching the button component. A strip whose members size themselves from padding ends up 2px out of line with the icon-only button sitting next to it."
      ],
      "anatomy": [
        {
          "part": "Track",
          "description": "For a choice: rounded-lg bg-zinc-200 with p-0.5, so the active pill has somewhere to sit."
        },
        {
          "part": "Active pill",
          "description": "bg-white with a shadow, inside the track. White against the tint is what reads as chosen — a darker tint does not."
        },
        {
          "part": "Strip",
          "description": "For actions: an inline-flex with a single border-zinc-200 around the outside and border-l between."
        },
        {
          "part": "Divider",
          "description": "border-l on every button except the first. One border, not two."
        },
        {
          "part": "End radii",
          "description": "Applied to the first and last buttons rather than clipping the container, so focus rings survive."
        },
        {
          "part": "Chevron",
          "description": "On a split button, the second half. It opens the menu; the first half does the action without opening anything."
        }
      ],
      "behaviour": [
        "A single-choice group behaves as one Tab stop: Tab enters it, arrow keys move within it, Tab leaves it.",
        "Arrow keys wrap around at both ends, so Left from the first option lands on the last.",
        "Choosing an option applies it immediately. A group with an Apply button beside it is a form, not a group.",
        "A split button's main half acts and its chevron half opens; clicking the main half never opens the menu.",
        "Below sm a group of more than three collapses to a select, because a fourth joined button either wraps or scrolls.",
        "Disabled members keep their place in the strip. Removing one on the fly changes which buttons are on the ends and reshapes the whole group."
      ],
      "accessibility": [
        "Single choice is role=\"radiogroup\" with role=\"radio\" and aria-checked on each option, plus a roving tabindex so the group is one stop.",
        "The group carries an aria-label naming what is being chosen — \"Date range\", not \"Options\".",
        "A group of independent toggles is role=\"group\" with aria-pressed on each, which is a different thing from a radiogroup and must not be confused with it.",
        "Icon-only members each carry aria-label; the group label does not name them.",
        "A split button's chevron has its own aria-label and aria-expanded, because \"Approve\" is already taken by the half beside it.",
        "The focus ring is never clipped — this is why the container is not overflow-hidden."
      ],
      "related": [
        "button",
        "dropdown",
        "tabs"
      ],
      "variants": [
        {
          "id": "segmented",
          "name": "Choosing one of a set",
          "html": "<!-- A real radiogroup: one Tab stop, arrows to move, aria-checked to announce.\n     The roving tabindex is what makes it one stop — every unchecked option is\n     tabindex=\"-1\", so Tab skips straight past them.\n\n     The track is bg-zinc-200 and the active pill is white. White on a tint is\n     what reads as chosen; a darker tint on a lighter tint does not, and it\n     leaves the unchosen options looking disabled. -->\n<div role=\"radiogroup\" aria-label=\"Date range\" x-ref=\"grp\"\n     x-data=\"{\n       v: 'Week',\n       opts: ['Day', 'Week', 'Month', 'Quarter'],\n       move(step) {\n         const i = (this.opts.indexOf(this.v) + step + this.opts.length) % this.opts.length;\n         this.v = this.opts[i];\n         this.$nextTick(() => this.$refs.grp.querySelector('[aria-checked=true]').focus());\n       }\n     }\"\n     @keydown.arrow-right.prevent=\"move(1)\" @keydown.arrow-left.prevent=\"move(-1)\"\n     class=\"inline-flex rounded-lg bg-zinc-200 p-0.5\">\n  <template x-for=\"o in opts\" :key=\"o\">\n    <button type=\"button\" role=\"radio\" :aria-checked=\"v === o\" :tabindex=\"v === o ? 0 : -1\"\n            @click=\"v = o\"\n            class=\"inline-flex h-8 items-center rounded-md px-3 text-[13px]/5 font-medium transition\"\n            :class=\"v === o ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-600 hover:text-zinc-900'\">\n      <span x-text=\"o\"></span>\n    </button>\n  </template>\n</div>"
        },
        {
          "id": "attached",
          "name": "Actions that belong together",
          "html": "<!-- A bordered strip, which says \"these do different things\" — the opposite of\n     the tinted track above. One border around the outside, border-l between,\n     and the radii on the end buttons rather than overflow-hidden on the\n     container, so the focus ring is not clipped off either end. -->\n<div class=\"inline-flex rounded-lg border border-zinc-200 bg-white\">\n  <button type=\"button\" class=\"inline-flex h-8 items-center gap-2 rounded-l-lg px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"pencil\" class=\"size-4 text-zinc-600\"></i>Edit\n  </button>\n  <button type=\"button\" class=\"inline-flex h-8 items-center gap-2 border-l border-zinc-200 px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"copy\" class=\"size-4 text-zinc-600\"></i>Duplicate\n  </button>\n  <button type=\"button\" class=\"inline-flex h-8 items-center gap-2 rounded-r-lg border-l border-zinc-200 px-3 text-[13px]/5 font-medium text-red-600 hover:bg-red-50\">\n    <i data-lucide=\"trash-2\" class=\"size-4\"></i>Delete\n  </button>\n</div>"
        },
        {
          "id": "icon-toolbar",
          "name": "Icon-only strip",
          "html": "<!-- Table density and column controls. Square buttons so the hit area is\n     square, each with its own aria-label — the group label does not name the\n     members, so \"Toolbar\" on the wrapper would leave three unnamed buttons.\n\n     The pressed one is aria-pressed, not aria-checked: these are independent\n     toggles, and only the density one is a choice. -->\n<div class=\"flex flex-wrap items-center gap-3\">\n  <div role=\"radiogroup\" aria-label=\"Row density\" x-ref=\"dens\"\n       x-data=\"{\n         v: 'comfortable',\n         opts: ['compact', 'comfortable'],\n         move(step) {\n           const i = (this.opts.indexOf(this.v) + step + this.opts.length) % this.opts.length;\n           this.v = this.opts[i];\n           this.$nextTick(() => this.$refs.dens.querySelector('[aria-checked=true]').focus());\n         }\n       }\"\n       @keydown.arrow-right.prevent=\"move(1)\" @keydown.arrow-left.prevent=\"move(-1)\"\n       class=\"inline-flex rounded-lg bg-zinc-200 p-0.5\">\n    <button type=\"button\" role=\"radio\" :aria-checked=\"v === 'compact'\" :tabindex=\"v === 'compact' ? 0 : -1\"\n            @click=\"v = 'compact'\" aria-label=\"Compact rows\"\n            class=\"flex size-8 items-center justify-center rounded-md transition\"\n            :class=\"v === 'compact' ? 'bg-white shadow-sm' : 'text-zinc-600 hover:text-zinc-900'\">\n      <i data-lucide=\"align-justify\" class=\"size-4\"></i>\n    </button>\n    <button type=\"button\" role=\"radio\" :aria-checked=\"v === 'comfortable'\" :tabindex=\"v === 'comfortable' ? 0 : -1\"\n            @click=\"v = 'comfortable'\" aria-label=\"Comfortable rows\"\n            class=\"flex size-8 items-center justify-center rounded-md transition\"\n            :class=\"v === 'comfortable' ? 'bg-white shadow-sm' : 'text-zinc-600 hover:text-zinc-900'\">\n      <i data-lucide=\"menu\" class=\"size-4\"></i>\n    </button>\n  </div>\n\n  <div role=\"group\" aria-label=\"Table view\" class=\"inline-flex rounded-lg border border-zinc-200 bg-white\">\n    <button type=\"button\" aria-label=\"Freeze first column\" aria-pressed=\"true\"\n            class=\"flex size-8 items-center justify-center rounded-l-lg bg-zinc-200 hover:bg-zinc-300\">\n      <i data-lucide=\"pin\" class=\"size-4\"></i>\n    </button>\n    <button type=\"button\" aria-label=\"Show totals row\" aria-pressed=\"false\"\n            class=\"flex size-8 items-center justify-center border-l border-zinc-200 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n      <i data-lucide=\"sigma\" class=\"size-4\"></i>\n    </button>\n    <button type=\"button\" aria-label=\"Wrap long text\" aria-pressed=\"false\"\n            class=\"flex size-8 items-center justify-center rounded-r-lg border-l border-zinc-200 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n      <i data-lucide=\"wrap-text\" class=\"size-4\"></i>\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "split",
          "name": "Split button",
          "html": "<!-- One button, two halves. The left half approves; the right half opens the\n     variations. Clicking the left half never opens the menu — that is the whole\n     reason for splitting it rather than making the entire thing a dropdown.\n\n     The chevron carries its own aria-label because \"Approve\" is already the\n     name of the half beside it, and its own aria-expanded because it is the\n     half that opens something. The divider is border-zinc-600, one step\n     lighter than the fill, because a zinc-200 rule on a dark button reads as a\n     crack rather than a join. -->\n<div class=\"relative inline-flex\" x-data=\"{ open: false }\"\n     @click.outside=\"open = false\" @keydown.escape.window=\"open = false\">\n  <button type=\"button\" class=\"inline-flex h-9 items-center rounded-l-lg bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n    Approve\n  </button>\n  <button type=\"button\" @click.stop=\"open = !open\"\n          aria-label=\"More approval options\" :aria-expanded=\"open\" aria-haspopup=\"menu\"\n          class=\"inline-flex h-9 items-center rounded-r-lg border-l border-zinc-600 bg-zinc-700 px-2 text-white hover:bg-zinc-800\">\n    <i data-lucide=\"chevron-down\" class=\"size-4\"></i>\n  </button>\n\n  <div x-show=\"open\" x-cloak @click.stop role=\"menu\"\n       class=\"absolute top-full right-0 z-40 mt-1 w-60 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"check-check\" class=\"size-4 text-zinc-600\"></i>Approve and close\n    </button>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"user-round-plus\" class=\"size-4 text-zinc-600\"></i>Approve and forward\n    </button>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 border-t border-zinc-200 px-3 py-2 text-left text-[13px]/5 text-red-600 hover:bg-red-50\">\n      <i data-lucide=\"circle-x\" class=\"size-4\"></i>Reject\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "input",
          "name": "Attached to an input",
          "html": "<!-- The border lives on the wrapper, not on the input, so there is one rule\n     around the pair instead of two abutting ones. The input is transparent and\n     borderless inside it, and focus-within moves the ring to the wrapper — put\n     it on the input and the ring is drawn inside the border it shares. -->\n<div class=\"flex max-w-md items-stretch rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n  <label for=\"add-part\" class=\"sr-only\">Part number</label>\n  <input id=\"add-part\" type=\"text\" placeholder=\"Part number\"\n         class=\"w-full min-w-0 rounded-l-lg bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  <button type=\"button\" class=\"inline-flex shrink-0 items-center gap-2 rounded-r-lg border-l border-zinc-200 px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"plus\" class=\"size-4 text-zinc-600\"></i>Add line\n  </button>\n</div>"
        },
        {
          "id": "vertical",
          "name": "Stacked",
          "html": "<!-- For a narrow column where a horizontal strip would not fit. Same rules\n     rotated: one border outside, border-t between, radii on the ends. Buttons\n     are left-aligned rather than centred, so the labels form a reading column\n     the way a menu does. -->\n<div role=\"group\" aria-label=\"Record actions\" class=\"inline-flex w-56 flex-col rounded-lg border border-zinc-200 bg-white\">\n  <button type=\"button\" class=\"flex h-9 items-center gap-2.5 rounded-t-lg px-3 text-left text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"file-check-2\" class=\"size-4 text-zinc-600\"></i>Record GRN\n  </button>\n  <button type=\"button\" class=\"flex h-9 items-center gap-2.5 border-t border-zinc-200 px-3 text-left text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"receipt\" class=\"size-4 text-zinc-600\"></i>Attach invoice\n  </button>\n  <button type=\"button\" class=\"flex h-9 items-center gap-2.5 border-t border-zinc-200 px-3 text-left text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"history\" class=\"size-4 text-zinc-600\"></i>Amendment history\n  </button>\n  <button type=\"button\" class=\"flex h-9 items-center gap-2.5 rounded-b-lg border-t border-zinc-200 px-3 text-left text-[13px]/5 font-medium text-red-600 hover:bg-red-50\">\n    <i data-lucide=\"circle-x\" class=\"size-4\"></i>Cancel order\n  </button>\n</div>"
        },
        {
          "id": "responsive",
          "name": "On a phone",
          "html": "<!-- Four joined buttons do not fit a 390px screen, and a group that wraps has\n     stopped being one object — the join breaks and two of the four end up with\n     the wrong radii. Below sm it is a select instead, which is one tap and\n     costs no width at all.\n\n     The two share one x-data, so whichever the user touches, both agree. -->\n<div x-data=\"{\n       v: 'Week',\n       opts: ['Day', 'Week', 'Month', 'Quarter'],\n       move(step) {\n         const i = (this.opts.indexOf(this.v) + step + this.opts.length) % this.opts.length;\n         this.v = this.opts[i];\n         this.$nextTick(() => this.$refs.grp.querySelector('[aria-checked=true]').focus());\n       }\n     }\">\n  <label for=\"range-sm\" class=\"sr-only\">Date range</label>\n  <select id=\"range-sm\" x-model=\"v\"\n          class=\"w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium outline-none focus:border-zinc-700 focus:ring-3 focus:ring-zinc-700/15 sm:hidden\">\n    <template x-for=\"o in opts\" :key=\"o\"><option :value=\"o\" x-text=\"o\"></option></template>\n  </select>\n\n  <div role=\"radiogroup\" aria-label=\"Date range\" x-ref=\"grp\"\n       @keydown.arrow-right.prevent=\"move(1)\" @keydown.arrow-left.prevent=\"move(-1)\"\n       class=\"hidden rounded-lg bg-zinc-200 p-0.5 sm:inline-flex\">\n    <template x-for=\"o in opts\" :key=\"o\">\n      <button type=\"button\" role=\"radio\" :aria-checked=\"v === o\" :tabindex=\"v === o ? 0 : -1\" @click=\"v = o\"\n              class=\"inline-flex h-8 items-center rounded-md px-3 text-[13px]/5 font-medium transition\"\n              :class=\"v === o ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-600 hover:text-zinc-900'\">\n        <span x-text=\"o\"></span>\n      </button>\n    </template>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django filter group",
          "html": "<!-- A GET form, so the chosen range ends up in the querystring and the page is\n     linkable and bookmarkable. Real radio inputs do the work — no JavaScript,\n     no roving tabindex to maintain, and the browser gives arrow keys and the\n     radiogroup semantics for free.\n\n     The input is sr-only rather than hidden, because display:none takes it out\n     of the tab order and off the keyboard entirely. peer-checked styles the\n     label that follows it; peer-focus-visible puts the focus ring there too,\n     since the input itself cannot be seen.\n\n     # views.py\n     rng = request.GET.get('range', 'week')   # 'day' | 'week' | 'month' -->\n<form method=\"get\" class=\"inline-flex rounded-lg bg-zinc-200 p-0.5\">\n  {% for value, label in ranges %}\n    <div>\n      <input type=\"radio\" name=\"range\" id=\"range-{{ value }}\" value=\"{{ value }}\"\n             {% if value == selected %}checked{% endif %}\n             onchange=\"this.form.submit()\" class=\"peer sr-only\">\n      <label for=\"range-{{ value }}\"\n             class=\"block rounded-md px-3 py-1.5 text-[13px]/5 font-medium text-zinc-600 transition hover:text-zinc-900 peer-checked:bg-white peer-checked:text-zinc-900 peer-checked:shadow-sm peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/15\">\n        {{ label }}\n      </label>\n    </div>\n  {% endfor %}\n</form>"
        }
      ]
    },
    {
      "id": "dropdown",
      "name": "Dropdown menu",
      "category": "actions",
      "description": "A menu of actions anchored to a trigger. Destructive items sit last, below a divider.",
      "when_to_use": "Row actions, export options, account menus. More than about seven items means you want a page, not a menu.",
      "rules": [
        "@click.outside on the root closes the panel, so no document-level handler and no global store is needed to keep one menu open at a time. It fires on a click anywhere else, including on another menu's trigger, which is what opens that one and closes this one in the same gesture."
      ],
      "anatomy": [
        {
          "part": "Trigger",
          "description": "A button carrying a chevron, which is the only signal that anything is hidden behind it."
        },
        {
          "part": "Panel",
          "description": "Absolutely positioned, z-40, opening below and aligned to the trigger's edge."
        },
        {
          "part": "Item",
          "description": "A full-width button with the icon left of the label, so the labels form a single reading column."
        },
        {
          "part": "Divider",
          "description": "Its own <div role=\"separator\" class=\"my-1 h-px bg-zinc-100\">, full bleed inside the panel padding. Not a border-t on the item below it: that rule belongs to the item, so it follows it to the top of the panel when whatever sat above is hidden."
        },
        {
          "part": "Destructive item",
          "description": "Last, below the divider, in red-600 — never adjacent to an item someone reaches for often."
        }
      ],
      "behaviour": [
        "Clicking the trigger toggles the panel and clicking outside closes it. Down or Up on the trigger also opens it, landing on the first or the last item, so the menu can be reached without a pointer at all.",
        "Escape closes the panel and returns focus to the trigger.",
        "Choosing an item closes the menu and hands focus back to the trigger — a menu that stays open after a choice reads as though the click was missed, and one that closes onto nothing loses the keyboard its place.",
        "Only one menu is open at a time. @click.outside handles this without a global store.",
        "Past about seven items this is the wrong control; the answer is a page with search, not a longer menu."
      ],
      "accessibility": [
        "The trigger carries aria-haspopup=\"menu\" and aria-expanded bound to the open state.",
        "The panel is role=\"menu\" and each item role=\"menuitem\".",
        "Down and Up move between items and wrap, Home and End jump to the ends, Escape closes and Tab closes without swallowing the tab. The separator is skipped because the walk queries [role=menuitem] rather than stepping through child elements, so it never lands on a line.",
        "Focus is real focus, moved item to item with tabindex=\"-1\" on each. This is where a menu and a combobox part company: a combobox keeps focus in its text box and points at a row with aria-activedescendant, but a menuitem is the thing being operated, so it has to be the thing focused. Down on the trigger opens onto the first item, Up onto the last, and Escape or a choice returns focus to the trigger. Clicking outside closes without moving focus, because the user has already put it somewhere else.",
        "The destructive item is distinguished by its label as well as its colour, since colour alone is not a signal."
      ],
      "related": [
        "button",
        "command-palette",
        "separator"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- The divider is its own element, not a border-t on the Delete button.\n     Hung off the button it belongs to the button, so an item above it that is\n     hidden by a permission check takes the rule to the top of the panel where\n     it introduces nothing. As its own element it drops out with the same\n     {% if %} as the item it introduces.\n\n     zinc-100, not zinc-200: the panel edge is the zinc-200, and a rule inside\n     an already bordered surface is a step lighter. role=\"separator\" is real\n     here because the panel is a role=\"menu\" and a menu is a list of peers.\n\n     A menu moves real focus between its items, one tabindex=\"-1\" at a time.\n     That is the opposite of the combobox next door, which keeps focus in the\n     text box and points at a row with aria-activedescendant, and the two are\n     not interchangeable: a menuitem is the thing being operated, so it has to\n     be the thing focused. items() reads the buttons out of the DOM on every\n     keystroke rather than off a list, so a permission check that drops an item\n     drops it from the keyboard order too, and the separator is skipped for\n     free because it is not a menuitem. The focused item takes a tint and an\n     inset outline rather than outline-none plus a tint: the panel is\n     overflow-hidden so a positive offset would be clipped, and in forced\n     colours the tint is dropped and the outline is all that is left.\n\n     The opening focus is moved on the next animation frame, not in $nextTick.\n     x-show has not written display yet when $nextTick runs: measured, the panel\n     was still display none with offsetHeight 0 inside the callback, and\n     focus() on a hidden button is a silent no-op that leaves the caret on the\n     trigger. It only shows up when the trigger already had focus, because\n     otherwise there is nothing to notice: a menu that opens with focus still\n     outside it is a menu the arrow keys do not drive. -->\n<div class=\"relative\"\n     x-data=\"{\n       open: false,\n       items() { return [...this.$refs.menu.querySelectorAll('[role=menuitem]')] },\n       show(last = false) {\n         this.open = true;\n         this.$nextTick(() => requestAnimationFrame(() => {\n           const i = this.items(); (last ? i[i.length - 1] : i[0])?.focus();\n         }));\n       },\n       close(toTrigger = true) {\n         if (!this.open) return;\n         this.open = false;\n         if (toTrigger) this.$refs.trigger.focus();\n       },\n       move(step) {\n         const i = this.items(), at = i.indexOf(document.activeElement);\n         i[(at + step + i.length) % i.length]?.focus();\n       },\n       edge(last) { const i = this.items(); (last ? i[i.length - 1] : i[0])?.focus() }\n     }\"\n     @click.outside=\"close(false)\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close() }\">\n  <button type=\"button\" x-ref=\"trigger\" @click=\"open ? close(false) : show()\"\n          @keydown.arrow-down.prevent=\"show()\" @keydown.arrow-up.prevent=\"show(true)\"\n          :aria-expanded=\"open\" aria-haspopup=\"menu\"\n          class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"download\" class=\"size-4\"></i>Export\n    <i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-600\"></i>\n  </button>\n  <div x-show=\"open\" x-cloak x-ref=\"menu\" role=\"menu\" aria-label=\"Export options\"\n       @keydown.arrow-down.prevent=\"move(1)\" @keydown.arrow-up.prevent=\"move(-1)\"\n       @keydown.home.prevent=\"edge(false)\" @keydown.end.prevent=\"edge(true)\"\n       @keydown.tab=\"close(false)\"\n       class=\"absolute left-0 z-40 mt-1 w-52 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n    <button type=\"button\" role=\"menuitem\" tabindex=\"-1\" @click=\"close()\"\n            class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100 focus:bg-zinc-100 focus:outline-2 focus:-outline-offset-2 focus:outline-zinc-700\">\n      <i data-lucide=\"sheet\" class=\"size-4 text-zinc-600\"></i>Excel (.xlsx)\n    </button>\n    <button type=\"button\" role=\"menuitem\" tabindex=\"-1\" @click=\"close()\"\n            class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100 focus:bg-zinc-100 focus:outline-2 focus:-outline-offset-2 focus:outline-zinc-700\">\n      <i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i>CSV\n    </button>\n\n    <div role=\"separator\" class=\"my-1 h-px bg-zinc-100\"></div>\n\n    <button type=\"button\" role=\"menuitem\" tabindex=\"-1\" @click=\"close()\"\n            class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 text-red-600 hover:bg-zinc-100 focus:bg-zinc-100 focus:outline-2 focus:-outline-offset-2 focus:outline-zinc-700\">\n      <i data-lucide=\"trash-2\" class=\"size-4\"></i>Delete\n    </button>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "table",
      "name": "Table",
      "category": "data",
      "description": "Rows of records with a sortable header. The workhorse of every register screen — order lists, GRN lists, ledgers.",
      "when_to_use": "More than about five records with more than two attributes each. For two or three fields per record a definition list reads better.",
      "rules": [
        "Money and quantity cells get tabular-nums and text-right so digits line up. Text cells stay left.",
        "Status pills use the locked mapping: Open graphite, Approved amber, Overdue red, Closed emerald, Draft muted zinc. Do not reinterpret it per screen.",
        "Below md the table must not scroll sideways — render the same rows as stacked cards instead.",
        "The sort indicator belongs inside the <th> button, not beside the table. Only one column is sorted at a time.",
        "Selection state is an array of PO numbers on the component root. Nothing outside the snippet is read."
      ],
      "anatomy": [
        {
          "part": "Header cell",
          "description": "A button inside the <th>, carrying the label and the sort indicator. The button is what is clickable, not the cell."
        },
        {
          "part": "Row",
          "description": "One record. Hover tints it zinc-50; selection tints it zinc-100 so the two states stay distinguishable."
        },
        {
          "part": "Numeric cell",
          "description": "tabular-nums and text-right, so digits stack into a readable column."
        },
        {
          "part": "Status cell",
          "description": "A pill from the locked mapping, and the only colour in the row."
        },
        {
          "part": "Action cell",
          "description": "Right-aligned, shrink-0, holding the row menu. Never wider than it needs to be."
        },
        {
          "part": "Stacked card",
          "description": "The same record rendered as a card below md, because a table that scrolls sideways on a phone is unusable."
        }
      ],
      "behaviour": [
        "One column is sorted at a time, and the indicator sits in that column's header, never floating beside the table.",
        "Clicking a sorted header reverses it; clicking a different header moves the sort and resets to ascending.",
        "Selection lives as an array of record ids on the component root, so nothing outside the snippet has to be wired up.",
        "The header checkbox reflects three states — none, some, all — and 'some' is indeterminate, not unchecked.",
        "Below md the table becomes stacked cards showing the same fields in the same order. It does not scroll sideways and columns are not hidden silently.",
        "Row hover and row selection are visually distinct, because a user scanning a selection needs to tell them apart."
      ],
      "accessibility": [
        "Header cells are <th scope=\"col\">, so a screen reader can name the column when reading a cell.",
        "The sortable header is a real button inside the <th>, and the <th> carries aria-sort reflecting the current direction.",
        "Each row checkbox has a label naming its record — twelve checkboxes all labelled \"Select\" are useless.",
        "The stacked-card layout carries the same information, not a reduced subset, so a phone user is not given less data.",
        "Row actions are reachable by keyboard; a menu that only appears on hover is invisible to anyone who cannot hover."
      ],
      "related": [
        "pagination",
        "empty-state",
        "skeleton"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"px-4 py-2.5\">\n          <button class=\"flex items-center gap-1 text-[11px]/4 font-medium tracking-wider text-zinc-900 uppercase\">\n            PO number <i data-lucide=\"chevron-up\" class=\"size-3.5\"></i>\n          </button>\n        </th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Department</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Status</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Due</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Fabrication</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-900\">\n            <span class=\"size-1.5 rounded-full bg-zinc-700\"></span>Open\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-600 tabular-nums\">12 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1191</td>\n        <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Dispatch</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹4,68,500</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n            <span class=\"size-1.5 rounded-full bg-amber-500\"></span>Approved\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-600 tabular-nums\">19 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Compounding</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹27,10,400</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n            <span class=\"size-1.5 rounded-full bg-red-600\"></span>Overdue\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-600 tabular-nums\">02 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1203</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Tooling</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹96,750</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n            <span class=\"size-1.5 rounded-full bg-emerald-600\"></span>Closed\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-600 tabular-nums\">28 Jul</td>\n      </tr>\n      <tr class=\"hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1206</td>\n        <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Maintenance</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹1,32,900</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-600\">\n            <span class=\"size-1.5 rounded-full bg-zinc-400\" aria-hidden=\"true\"></span>Draft\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-500 tabular-nums\">—</td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "selection",
          "name": "With selection",
          "html": "<div x-data=\"{ all: ['PO-24-1187','PO-24-1191','PO-24-1194'], sel: [] }\"\n     class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div x-show=\"sel.length\" x-cloak\n       class=\"flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-zinc-100 px-4 py-2\">\n    <span class=\"text-[13px]/5 font-medium\"><span x-text=\"sel.length\"></span> selected</span>\n    <div class=\"flex flex-wrap items-center gap-2\">\n      <button class=\"rounded-lg bg-zinc-700 px-3 py-1.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n      <button class=\"rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">Export</button>\n      <button class=\"rounded-lg px-3 py-1.5 text-[13px]/5 font-medium text-red-600 hover:bg-zinc-100\">Cancel orders</button>\n    </div>\n    <button @click=\"sel = []\" class=\"ml-auto text-[13px]/5 text-zinc-600 underline underline-offset-2\">Clear</button>\n  </div>\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"w-10 px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select all orders\" class=\"size-4 rounded accent-zinc-700\"\n                 :checked=\"sel.length === all.length\"\n                 @change=\"sel = $event.target.checked ? [...all] : []\">\n        </th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Status</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100\" :class=\"sel.includes('PO-24-1187') ? 'bg-zinc-100' : 'hover:bg-zinc-100'\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" value=\"PO-24-1187\" x-model=\"sel\" aria-label=\"Select PO-24-1187\" class=\"size-4 rounded accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-900\">\n            <span class=\"size-1.5 rounded-full bg-zinc-700\"></span>Open\n          </span>\n        </td>\n      </tr>\n      <tr class=\"border-b border-zinc-100\" :class=\"sel.includes('PO-24-1191') ? 'bg-zinc-100' : 'hover:bg-zinc-100'\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" value=\"PO-24-1191\" x-model=\"sel\" aria-label=\"Select PO-24-1191\" class=\"size-4 rounded accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1191</td>\n        <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹4,68,500</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n            <span class=\"size-1.5 rounded-full bg-amber-500\"></span>Approved\n          </span>\n        </td>\n      </tr>\n      <tr :class=\"sel.includes('PO-24-1194') ? 'bg-zinc-100' : 'hover:bg-zinc-100'\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" value=\"PO-24-1194\" x-model=\"sel\" aria-label=\"Select PO-24-1194\" class=\"size-4 rounded accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹27,10,400</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n            <span class=\"size-1.5 rounded-full bg-red-600\"></span>Overdue\n          </span>\n        </td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "dense",
          "name": "Dense",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"px-3 py-1.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-3 py-1.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-3 py-1.5 font-medium\">Dept</th>\n        <th scope=\"col\" class=\"px-3 py-1.5 text-right font-medium\">Amount</th>\n        <th scope=\"col\" class=\"px-3 py-1.5 text-right font-medium\">Due</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-3 py-1.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-3 py-1.5\">Sharma Extrusions</td>\n        <td class=\"px-3 py-1.5 text-zinc-600\">Fabrication</td>\n        <td class=\"px-3 py-1.5 text-right tabular-nums\">₹18,42,000</td>\n        <td class=\"px-3 py-1.5 text-right text-zinc-600 tabular-nums\">12 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-3 py-1.5 font-medium tabular-nums\">PO-24-1191</td>\n        <td class=\"px-3 py-1.5\">Nashik Steel Traders</td>\n        <td class=\"px-3 py-1.5 text-zinc-600\">Dispatch</td>\n        <td class=\"px-3 py-1.5 text-right tabular-nums\">₹4,68,500</td>\n        <td class=\"px-3 py-1.5 text-right text-zinc-600 tabular-nums\">19 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-3 py-1.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-3 py-1.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-3 py-1.5 text-zinc-600\">Compounding</td>\n        <td class=\"px-3 py-1.5 text-right tabular-nums\">₹27,10,400</td>\n        <td class=\"px-3 py-1.5 text-right text-red-600 tabular-nums\">02 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-3 py-1.5 font-medium tabular-nums\">PO-24-1199</td>\n        <td class=\"px-3 py-1.5\">Nashik Steel Traders</td>\n        <td class=\"px-3 py-1.5 text-zinc-600\">Maintenance</td>\n        <td class=\"px-3 py-1.5 text-right tabular-nums\">₹1,32,900</td>\n        <td class=\"px-3 py-1.5 text-right text-zinc-600 tabular-nums\">22 Aug</td>\n      </tr>\n      <tr class=\"hover:bg-zinc-100\">\n        <td class=\"px-3 py-1.5 font-medium tabular-nums\">PO-24-1203</td>\n        <td class=\"px-3 py-1.5\">Sharma Extrusions</td>\n        <td class=\"px-3 py-1.5 text-zinc-600\">Tooling</td>\n        <td class=\"px-3 py-1.5 text-right tabular-nums\">₹96,750</td>\n        <td class=\"px-3 py-1.5 text-right text-zinc-600 tabular-nums\">28 Jul</td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "responsive",
          "name": "Responsive",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"hidden w-full text-[13px]/5 md:table\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Department</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Due</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100 hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Fabrication</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n        <td class=\"px-4 py-2.5 text-right text-zinc-600 tabular-nums\">12 Aug</td>\n      </tr>\n      <tr class=\"hover:bg-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-zinc-600\">Compounding</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹27,10,400</td>\n        <td class=\"px-4 py-2.5 text-right text-red-600 tabular-nums\">02 Aug</td>\n      </tr>\n    </tbody>\n  </table>\n\n  <ul class=\"divide-y divide-zinc-100 md:hidden\">\n    <li class=\"px-4 py-3\">\n      <div class=\"flex items-baseline justify-between gap-3\">\n        <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1187</span>\n        <span class=\"text-[14px]/5 tabular-nums\">₹18,42,000</span>\n      </div>\n      <p class=\"mt-0.5 text-[13px]/5 text-zinc-600\">Sharma Extrusions · Fabrication</p>\n      <div class=\"mt-2 flex items-center gap-2\">\n        <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-900\">\n          <span class=\"size-1.5 rounded-full bg-zinc-700\"></span>Open\n        </span>\n        <span class=\"text-[12px]/4 text-zinc-500 tabular-nums\">Due 12 Aug</span>\n      </div>\n    </li>\n    <li class=\"px-4 py-3\">\n      <div class=\"flex items-baseline justify-between gap-3\">\n        <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1194</span>\n        <span class=\"text-[14px]/5 tabular-nums\">₹27,10,400</span>\n      </div>\n      <p class=\"mt-0.5 text-[13px]/5 text-zinc-600\">Gujarat Polymers Ltd · Compounding</p>\n      <div class=\"mt-2 flex items-center gap-2\">\n        <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 text-zinc-700\">\n          <span class=\"size-1.5 rounded-full bg-red-600\"></span>Overdue\n        </span>\n        <span class=\"text-[12px]/4 text-zinc-500 tabular-nums\">Due 02 Aug</span>\n      </div>\n    </li>\n  </ul>\n</div>"
        }
      ]
    },
    {
      "id": "pagination",
      "name": "Pagination",
      "category": "data",
      "description": "Moves through a long register a page at a time, and says where you are in it.",
      "when_to_use": "Any list the server pages. Always show the range and the total — \"Next\" with no count tells the user nothing.",
      "rules": [
        "Simple prev/next is enough below about ten pages. Numbers only earn their space when someone needs to jump.",
        "The current page gets aria-current=\"page\". Disabled ends are real disabled buttons, not removed ones, so the control does not jump."
      ],
      "anatomy": [
        {
          "part": "Range",
          "description": "\"Showing 21–40 of 1,438\" — where you are and how much there is. Not optional."
        },
        {
          "part": "Previous / next",
          "description": "The controls people actually use. Disabled at the ends rather than removed."
        },
        {
          "part": "Page numbers",
          "description": "Only when someone genuinely needs to jump. Below about ten pages they are noise."
        },
        {
          "part": "Current page",
          "description": "Marked with aria-current and a solid graphite fill, so it reads as position and not as a button to press."
        },
        {
          "part": "Page size",
          "description": "Optional select. Changing it returns to page one, because page 7 of the old size means nothing at the new one."
        }
      ],
      "behaviour": [
        "Ends disable rather than disappear, so the control keeps its width and the buttons stay under the cursor.",
        "Changing the page size resets to the first page and says how many rows are now shown.",
        "The range text updates with the page, and is the only thing that tells the user how much is left.",
        "Page numbers collapse with an ellipsis rather than growing without limit past about ten pages.",
        "The control sits below the table and inside the same panel, so it does not drift away from what it pages."
      ],
      "accessibility": [
        "The whole control is a <nav> with aria-label=\"Pagination\", so it can be skipped.",
        "The current page carries aria-current=\"page\".",
        "Disabled ends are real disabled buttons, skipped by Tab and announced as unavailable.",
        "Every number is a link or button with an accessible name of the form \"Page 4\", never a bare digit.",
        "The range line is real text, not a title attribute, so it is read out with the rest of the page."
      ],
      "related": [
        "table",
        "empty-state",
        "dropdown"
      ],
      "variants": [
        {
          "id": "simple",
          "name": "Simple",
          "html": "<div class=\"flex flex-wrap items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-2.5\">\n  <p class=\"text-[13px]/5 text-zinc-600 tabular-nums\">1–10 of 148 orders</p>\n  <div class=\"flex items-center gap-2\">\n    <button disabled class=\"flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[13px]/5 font-medium text-zinc-400\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>Previous\n    </button>\n    <button class=\"flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      Next<i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "numbered",
          "name": "Numbered",
          "html": "<nav aria-label=\"Order register pages\" class=\"flex flex-wrap items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-2.5\">\n  <p class=\"text-[13px]/5 text-zinc-600 tabular-nums\">31–40 of 148 orders</p>\n  <div class=\"flex items-center gap-1\">\n    <button aria-label=\"Previous page\" class=\"flex size-8 items-center justify-center rounded-lg border border-zinc-200 hover:bg-zinc-100\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n    <button class=\"size-8 rounded-lg text-[13px]/5 tabular-nums hover:bg-zinc-100\">1</button>\n    <button class=\"size-8 rounded-lg text-[13px]/5 tabular-nums hover:bg-zinc-100\">2</button>\n    <button class=\"size-8 rounded-lg text-[13px]/5 tabular-nums hover:bg-zinc-100\">3</button>\n    <button aria-current=\"page\" class=\"size-8 rounded-lg bg-zinc-700 text-[13px]/5 font-medium text-white tabular-nums\">4</button>\n    <button class=\"size-8 rounded-lg text-[13px]/5 tabular-nums hover:bg-zinc-100\">5</button>\n    <span class=\"px-1 text-[13px]/5 text-zinc-500\">…</span>\n    <button class=\"size-8 rounded-lg text-[13px]/5 tabular-nums hover:bg-zinc-100\">15</button>\n    <button aria-label=\"Next page\" class=\"flex size-8 items-center justify-center rounded-lg border border-zinc-200 hover:bg-zinc-100\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n</nav>"
        },
        {
          "id": "page-size",
          "name": "With page size",
          "html": "<div class=\"flex flex-wrap items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-2.5\">\n  <div class=\"flex items-center gap-2\">\n    <label for=\"page-size\" class=\"text-[13px]/5 text-zinc-600\">Rows per page</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n      <select id=\"page-size\" class=\"bg-transparent px-2 py-1.5 text-[13px]/5 tabular-nums outline-none\">\n        <option>10</option>\n        <option selected>25</option>\n        <option>50</option>\n        <option>100</option>\n      </select>\n    </div>\n  </div>\n  <div class=\"flex flex-wrap items-center gap-3\">\n    <p class=\"text-[13px]/5 text-zinc-600 tabular-nums\">26–50 of 148 orders</p>\n    <div class=\"flex items-center gap-1\">\n      <button aria-label=\"Previous page\" class=\"flex size-8 items-center justify-center rounded-lg border border-zinc-200 hover:bg-zinc-100\">\n        <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n      </button>\n      <button aria-label=\"Next page\" class=\"flex size-8 items-center justify-center rounded-lg border border-zinc-200 hover:bg-zinc-100\">\n        <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n      </button>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "stat-card",
      "name": "Stat card",
      "category": "data",
      "description": "One number that matters, with the label above it and the change since last period below.",
      "when_to_use": "The top strip of a dashboard. Not for a number nobody acts on — a tile per metric is how dashboards become wallpaper.",
      "rules": [
        "The delta colour states whether the movement is good, not whether it is up. Rising overdue value is red even though the arrow points up.",
        "Always say what the delta is measured against. \"+12%\" alone is unreadable.",
        "<template x-for> does not work inside <svg>, so sparkline points are written out in the markup."
      ],
      "anatomy": [
        {
          "part": "Label",
          "description": "What the number is, in 11px uppercase. Above the figure, because you read the label first."
        },
        {
          "part": "Figure",
          "description": "The number itself, tabular-nums, at the display step. The largest thing in the card."
        },
        {
          "part": "Delta",
          "description": "The change and what it is measured against. \"+12% vs July\", never a bare \"+12%\"."
        },
        {
          "part": "Sparkline",
          "description": "Optional shape of the last few periods. Points are written out in the markup, since x-for cannot run inside an svg."
        },
        {
          "part": "Surface",
          "description": "A white card with a zinc border. No shadow — a dashboard of shadowed tiles reads as a toy."
        }
      ],
      "behaviour": [
        "The delta colour states whether the movement is good, not whether it points up. Rising overdue value is red with an up arrow.",
        "A card with no delta is still valid; a delta with no comparison period is not.",
        "Figures align across a row of cards because they all use tabular-nums at the same step.",
        "The card is a link only if there is somewhere to go; a card that looks clickable and is not is worse than a plain one.",
        "Four cards is the practical ceiling in a row. More than that and nobody reads any of them."
      ],
      "accessibility": [
        "Label and figure are one readable unit — a <dl> pairing, not two unrelated blocks.",
        "The delta arrow is decorative; the sign and the word carry the meaning for anyone who cannot see the colour.",
        "The sparkline is aria-hidden. It shows shape, not value, and the figure beside it is the actual data.",
        "If the card links somewhere, the whole card is the link, so the target is not a 12px arrow.",
        "Nothing in the card depends on colour alone to say whether the movement is good."
      ],
      "related": [
        "progress",
        "card",
        "table"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Open order value</p>\n  <p class=\"mt-1.5 text-[24px]/7 tracking-tight font-semibold tabular-nums\">₹1,84,20,000</p>\n  <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n    <span class=\"inline-flex items-center gap-1 font-medium text-emerald-600\">\n      <i data-lucide=\"trending-up\" class=\"size-3.5\"></i>8.4%\n    </span>\n    <span class=\"text-zinc-500\">vs. last month</span>\n  </p>\n</div>"
        },
        {
          "id": "sparkline",
          "name": "With sparkline",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Orders raised — 12 weeks</p>\n  <div class=\"mt-1.5 flex items-end justify-between gap-4\">\n    <div>\n      <p class=\"text-[24px]/7 tracking-tight font-semibold tabular-nums\">148</p>\n      <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n        <span class=\"inline-flex items-center gap-1 font-medium text-emerald-600\">\n          <i data-lucide=\"trending-up\" class=\"size-3.5\"></i>11</span>\n        <span class=\"text-zinc-500\">vs. previous 12 weeks</span>\n      </p>\n    </div>\n    <svg viewBox=\"0 0 120 36\" class=\"h-9 w-30 shrink-0 text-zinc-500\" fill=\"none\" aria-hidden=\"true\">\n      <polyline points=\"0,28 11,24 22,26 33,18 44,21 55,14 66,16 77,9 88,12 99,7 110,10 120,4\"\n                stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></polyline>\n    </svg>\n  </div>\n</div>"
        },
        {
          "id": "grid",
          "name": "Grid of four",
          "html": "<div class=\"grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4\">\n  <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n    <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Open orders</p>\n    <p class=\"mt-1.5 text-[24px]/7 tracking-tight font-semibold tabular-nums\">148</p>\n    <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n      <span class=\"inline-flex items-center gap-1 font-medium text-emerald-600\"><i data-lucide=\"trending-up\" class=\"size-3.5\"></i>11</span>\n      <span class=\"text-zinc-500\">vs. last month</span>\n    </p>\n  </div>\n  <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n    <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Open value</p>\n    <p class=\"mt-1.5 text-[24px]/7 tracking-tight font-semibold tabular-nums\">₹1,84,20,000</p>\n    <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n      <span class=\"inline-flex items-center gap-1 font-medium text-emerald-600\"><i data-lucide=\"trending-up\" class=\"size-3.5\"></i>8.4%</span>\n      <span class=\"text-zinc-500\">vs. last month</span>\n    </p>\n  </div>\n  <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n    <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Overdue</p>\n    <p class=\"mt-1.5 text-[24px]/7 tracking-tight font-semibold tabular-nums\">₹27,10,400</p>\n    <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n      <span class=\"inline-flex items-center gap-1 font-medium text-red-600\"><i data-lucide=\"trending-up\" class=\"size-3.5\"></i>3 orders</span>\n      <span class=\"text-zinc-500\">past due date</span>\n    </p>\n  </div>\n  <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n    <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">Awaiting approval</p>\n    <p class=\"mt-1.5 text-[24px]/7 tracking-tight font-semibold tabular-nums\">9</p>\n    <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4\">\n      <span class=\"inline-flex items-center gap-1 font-medium text-zinc-600\"><i data-lucide=\"minus\" class=\"size-3.5\"></i>No change</span>\n      <span class=\"text-zinc-500\">since 12 Aug</span>\n    </p>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "chart",
      "name": "Chart",
      "category": "data",
      "description": "Chart.js on a canvas, wearing this system's colours. The legend is HTML and doubles as the palette the canvas paints from, so a swatch and the line it names cannot drift apart.",
      "when_to_use": "A shape a column of numbers will not show: a trend across months, a composition, a ranking. If the reader needs the exact figure, the table is the answer and the chart sits above it.",
      "rules": [
        "Chart.js 4 from a CDN, pinned to an exact version. It is the only runtime dependency beyond Alpine and Lucide, and it earns its place: a hand-rolled SVG chart is a year of edge cases, from tick density and label collision to stacking and retina canvases.",
        "A canvas cannot take a class, so read every colour out of the DOM at init: the series from the legend swatches, the grid from the card border, the tick text from the legend text. Never type a hex.",
        "Do not read --color-zinc-400 off :root either. Tailwind v4 tree-shakes theme variables it cannot see used, so in an app that uses zinc-400 nowhere else that lookup returns an empty string and Chart.js silently paints black. Naming the class inside x-init does not save you: the scanner reads class attributes, not strings in JavaScript, so the utility is never generated in the first place.",
        "The legend is HTML above the canvas, and it is also the palette. One item per series, each with a bg-* swatch that init reads back. There is one definition of the colour, so the square and the line it names cannot disagree.",
        "No colour in a chart, ever. This is the alert rule and the status pill rule applied to a field of shapes: an alert is a white card with colour only in its icon, a status pill is graphite with a single coloured dot, and a chart is graphite with none. Red across the top of every bar is a field of colour, and it shouts louder than the seven overdue orders it is reporting. The red dot belongs on the pill beside the record.",
        "A stroke and a fill are different problems, the same split the token table already makes between amber-700 in a component and amber-500 in a dot. A 2px line needs the dark end and takes zinc-800. Fills are large areas and come from zinc-200, zinc-500 and zinc-800, three steps apart so that neighbours separate; a single-series bar takes zinc-600. Filling half a chart with zinc-800 puts a slab of black on the page, which is why the dark end is reserved for strokes and for the smallest series.",
        "The darkest step goes to the series that matters, which is almost always the smallest one. Weight follows importance rather than volume: 72 closed orders are the pale bulk along the bottom and 7 overdue are the dark cap on top, and the cap is the only reason anyone opened the chart.",
        "The canvas needs a parent with a height, and maintainAspectRatio: false. Given neither, Chart.js measures the box it has just drawn into, grows, measures again, and the canvas expands every frame until the tab is unusable.",
        "destroy() the chart when Alpine tears the component down. Chart.js keeps a registry keyed on the canvas element, so re-rendering the same markup without it throws \"Canvas is already in use\".",
        "sr-only goes on a div wrapping the fallback table, never on the table itself. A table treats width: 1px as a minimum and grows to fit its content, and since sr-only is absolutely positioned that runaway width becomes page-level horizontal scroll. Measured at 390px it added 105px of sideways scroll to the page.",
        "A canvas is a picture with no text in it. Every chart takes role=\"img\" and a one-line aria-label that states the trend, and repeats its numbers as text: an sr-only table, or a legend that already carries the values the way the donut does. Without one of the two a screen reader gets nothing at all.",
        "Animation is 250ms, and off entirely under prefers-reduced-motion. A dashboard that replays a grow-from-zero sweep on every filter change is a dashboard nobody filters twice.",
        "No gradients, no shadows, and no truncated axis on a bar chart. Starting bars at anything but zero exaggerates the difference the chart exists to report.",
        "Format ticks in the units the reader uses. A y-axis of 1800000 is arithmetic; 18.0L is the number they would have said out loud."
      ],
      "anatomy": [
        {
          "part": "Frame",
          "description": "The card. Its zinc-200 border is also where the chart reads its grid colour."
        },
        {
          "part": "Legend",
          "description": "An HTML list above the canvas, one item per series. It is the palette, the accessible key, and the only place a series colour is written."
        },
        {
          "part": "Plot box",
          "description": "A wrapper with an explicit height. The canvas fills it; without it the chart grows without bound."
        },
        {
          "part": "Canvas",
          "description": "role=\"img\" with an aria-label summarising the shape, because nothing inside a canvas is readable."
        },
        {
          "part": "Fallback table",
          "description": "The same numbers, sr-only, immediately after the canvas."
        },
        {
          "part": "Footnote",
          "description": "Source and period, in tertiary text. A chart with no stated period is a chart nobody can check."
        }
      ],
      "behaviour": [
        "Colours resolve at init from the rendered DOM, so the chart matches whatever the stylesheet actually shipped.",
        "The chart is responsive: it fills the plot box and redraws on resize, which is why the box owns the height and the canvas never does.",
        "Hovering anywhere on a column reports every series at that point, rather than only the segment under the cursor.",
        "Animation runs for 250ms once, and not at all when the reader has asked for reduced motion.",
        "Tearing down the component destroys the Chart instance, so previews and htmx swaps can re-render the same markup safely.",
        "With no data the component renders an empty state, never an empty grid. Axes with nothing in them read as a chart that failed to load."
      ],
      "accessibility": [
        "The canvas carries role=\"img\" and an aria-label that says what the chart shows, not that it is a chart.",
        "Every chart repeats its numbers as real text: an sr-only table with proper headers right after the canvas, or a legend carrying the values, which is why the donut needs no table.",
        "The legend is HTML, so its labels are text a screen reader can announce and a browser can find. The Chart.js canvas legend is turned off system wide for that reason.",
        "Nothing is told apart by hue, because there is no hue: the ramp is lightness only, which is the one channel every reader has. The legend still names each series in text and the fallback table still carries every value.",
        "Three fills is the ceiling, and the arithmetic sets it. zinc-200, zinc-500 and zinc-800 clear 3:1 against the neighbour they touch, at 3.81 and 3.08; a fourth grey squeezed in between drops a pair below 3:1 and the two segments stop separating. The palest step is 1.27:1 against the white card, so the bottom of a stacked bar is defined by the axis rather than by its own edge. That is the one edge that does not clear, and the fallback table is what pays for it.",
        "Tooltips are pointer sugar and never the only place a value appears."
      ],
      "related": [
        "stat-card",
        "table",
        "progress"
      ],
      "variants": [
        {
          "id": "setup",
          "name": "Page setup",
          "html": "<!-- once per page, in the head, after Alpine and before your charts.\n     Pin the version: Chart.js ships breaking changes in minors. -->\n<script src=\"https://unpkg.com/chart.js@4.4.7/dist/chart.umd.js\"></script>\n<script>\n  /* Behaviour only. Nothing here reads the DOM: a head script runs before the\n     stylesheet has been applied, so getComputedStyle would hand back Times.\n     The font face is read per chart at init, where the colours are read too. */\n  Chart.defaults.font.size = 12;\n  Chart.defaults.plugins.legend.display = false;\n  Chart.defaults.plugins.tooltip.displayColors = false;\n  Chart.defaults.plugins.tooltip.padding = 8;\n  Chart.defaults.animation = matchMedia('(prefers-reduced-motion: reduce)').matches\n    ? false\n    : { duration: 250 };\n</script>"
        },
        {
          "id": "line",
          "name": "Line",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const grid = getComputedStyle(this.$root).borderTopColor;\n         const ink  = getComputedStyle(this.$refs.legend).color;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'line',\n           data: {\n             labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],\n             datasets: [{\n               label: 'Committed value',\n               data: [1420000, 1680000, 2310000, 1890000, 2040000, 2470000, 2260000, 2410000],\n               borderColor: tone[0], backgroundColor: tone[0],\n               borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3\n             }]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false,\n             interaction: { mode: 'index', intersect: false },\n             scales: {\n               x: { grid: { display: false }, border: { color: grid }, ticks: { color: ink } },\n               y: {\n                 beginAtZero: true,\n                 grid: { color: grid }, border: { display: false },\n                 ticks: { color: ink, padding: 8, callback: v => (v / 100000).toFixed(1).replace('.0', '') + 'L' }\n               }\n             },\n             plugins: {\n               tooltip: { callbacks: { label: c => '₹' + c.parsed.y.toLocaleString('en-IN') } }\n             }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Committed value by month</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Silvassa plant · 2026</p>\n  </div>\n\n  <ul x-ref=\"legend\" class=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]/4 text-zinc-600\">\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-800\" aria-hidden=\"true\"></span>Committed value\n    </li>\n  </ul>\n\n  <div class=\"mt-4 h-64\">\n    <canvas x-ref=\"canvas\" role=\"img\"\n            aria-label=\"Committed value by month, rising from ₹14.2 lakh in January to ₹24.1 lakh in August, with a dip in April.\"></canvas>\n  </div>\n\n  <div class=\"sr-only\">\n    <table>\n      <caption>Committed value by month, Silvassa plant, 2026</caption>\n      <thead><tr><th scope=\"col\">Month</th><th scope=\"col\">Committed value</th></tr></thead>\n      <tbody>\n        <tr><th scope=\"row\">January</th><td>₹14,20,000</td></tr>\n        <tr><th scope=\"row\">February</th><td>₹16,80,000</td></tr>\n        <tr><th scope=\"row\">March</th><td>₹23,10,000</td></tr>\n        <tr><th scope=\"row\">April</th><td>₹18,90,000</td></tr>\n        <tr><th scope=\"row\">May</th><td>₹20,40,000</td></tr>\n        <tr><th scope=\"row\">June</th><td>₹24,70,000</td></tr>\n        <tr><th scope=\"row\">July</th><td>₹22,60,000</td></tr>\n        <tr><th scope=\"row\">August</th><td>₹24,10,000</td></tr>\n      </tbody>\n    </table>\n  </div>\n</div>"
        },
        {
          "id": "bar",
          "name": "Grouped bar",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const grid = getComputedStyle(this.$root).borderTopColor;\n         const ink  = getComputedStyle(this.$refs.legend).color;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'bar',\n           data: {\n             labels: ['Apr', 'May', 'Jun', 'Jul', 'Aug'],\n             datasets: [\n               { label: 'Raised',   data: [92, 104, 118, 111, 128], backgroundColor: tone[0], borderRadius: 4, maxBarThickness: 22 },\n               { label: 'Received', data: [88, 96, 109, 104, 117],  backgroundColor: tone[1], borderRadius: 4, maxBarThickness: 22 }\n             ]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false,\n             interaction: { mode: 'index', intersect: false },\n             scales: {\n               x: { grid: { display: false }, border: { color: grid }, ticks: { color: ink } },\n               y: { beginAtZero: true, grid: { color: grid }, border: { display: false }, ticks: { color: ink, padding: 8 } }\n             }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Orders raised against received</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Last five months</p>\n  </div>\n\n  <ul x-ref=\"legend\" class=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]/4 text-zinc-600\">\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-600\" aria-hidden=\"true\"></span>Raised\n    </li>\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-300\" aria-hidden=\"true\"></span>Received\n    </li>\n  </ul>\n\n  <div class=\"mt-4 h-64\">\n    <canvas x-ref=\"canvas\" role=\"img\"\n            aria-label=\"Orders raised against orders received, April to August. Raised runs slightly ahead of received in every month, by between 4 and 11 orders.\"></canvas>\n  </div>\n\n  <div class=\"sr-only\">\n    <table>\n      <caption>Orders raised against received, April to August 2026</caption>\n      <thead><tr><th scope=\"col\">Month</th><th scope=\"col\">Raised</th><th scope=\"col\">Received</th></tr></thead>\n      <tbody>\n        <tr><th scope=\"row\">April</th><td>92</td><td>88</td></tr>\n        <tr><th scope=\"row\">May</th><td>104</td><td>96</td></tr>\n        <tr><th scope=\"row\">June</th><td>118</td><td>109</td></tr>\n        <tr><th scope=\"row\">July</th><td>111</td><td>104</td></tr>\n        <tr><th scope=\"row\">August</th><td>128</td><td>117</td></tr>\n      </tbody>\n    </table>\n  </div>\n</div>"
        },
        {
          "id": "horizontal",
          "name": "Ranking",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const grid = getComputedStyle(this.$root).borderTopColor;\n         const ink  = getComputedStyle(this.$refs.legend).color;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'bar',\n           data: {\n             labels: ['Gujarat Polymers Ltd', 'Nashik Steel Traders', 'Sharma Extrusions', 'Deshpande Traders', 'Vasai Packaging'],\n             datasets: [{\n               label: 'Committed value',\n               data: [4820000, 3140000, 2270000, 1480000, 910000],\n               backgroundColor: tone[0], borderRadius: 4, maxBarThickness: 18\n             }]\n           },\n           options: {\n             indexAxis: 'y',\n             responsive: true, maintainAspectRatio: false,\n             scales: {\n               x: {\n                 beginAtZero: true,\n                 grid: { color: grid }, border: { display: false },\n                 ticks: { color: ink, callback: v => (v / 100000).toFixed(1).replace('.0', '') + 'L' }\n               },\n               y: { grid: { display: false }, border: { color: grid }, ticks: { color: ink } }\n             },\n             plugins: {\n               tooltip: { callbacks: { label: c => '₹' + c.parsed.x.toLocaleString('en-IN') } }\n             }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Top suppliers by committed value</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Financial year 2026-27, to date</p>\n  </div>\n\n  <ul x-ref=\"legend\" class=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]/4 text-zinc-600\">\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-600\" aria-hidden=\"true\"></span>Committed value\n    </li>\n  </ul>\n\n  <div class=\"mt-4 h-64\">\n    <canvas x-ref=\"canvas\" role=\"img\"\n            aria-label=\"Top five suppliers by committed value. Gujarat Polymers leads at ₹48.2 lakh, ahead of Nashik Steel Traders at ₹31.4 lakh.\"></canvas>\n  </div>\n\n  <div class=\"sr-only\">\n    <table>\n      <caption>Top suppliers by committed value, financial year 2026-27 to date</caption>\n      <thead><tr><th scope=\"col\">Supplier</th><th scope=\"col\">Committed value</th></tr></thead>\n      <tbody>\n        <tr><th scope=\"row\">Gujarat Polymers Ltd</th><td>₹48,20,000</td></tr>\n        <tr><th scope=\"row\">Nashik Steel Traders</th><td>₹31,40,000</td></tr>\n        <tr><th scope=\"row\">Sharma Extrusions</th><td>₹22,70,000</td></tr>\n        <tr><th scope=\"row\">Deshpande Traders</th><td>₹14,80,000</td></tr>\n        <tr><th scope=\"row\">Vasai Packaging</th><td>₹9,10,000</td></tr>\n      </tbody>\n    </table>\n  </div>\n</div>"
        },
        {
          "id": "stacked",
          "name": "Stacked by status",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const grid = getComputedStyle(this.$root).borderTopColor;\n         const ink  = getComputedStyle(this.$refs.legend).color;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'bar',\n           data: {\n             labels: ['Apr', 'May', 'Jun', 'Jul', 'Aug'],\n             datasets: [\n               { label: 'Closed',  data: [53, 57, 66, 61, 72], backgroundColor: tone[0], maxBarThickness: 28 },\n               { label: 'Open',    data: [35, 41, 47, 41, 49], backgroundColor: tone[1], maxBarThickness: 28 },\n               { label: 'Overdue', data: [4, 6, 5, 9, 7],      backgroundColor: tone[2], maxBarThickness: 28 }\n             ]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false,\n             interaction: { mode: 'index', intersect: false },\n             scales: {\n               x: { stacked: true, grid: { display: false }, border: { color: grid }, ticks: { color: ink } },\n               y: { stacked: true, beginAtZero: true, grid: { color: grid }, border: { display: false }, ticks: { color: ink, padding: 8 } }\n             }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Orders by status</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Last five months</p>\n  </div>\n\n  <!-- Graphite only. Overdue takes the darkest step because it is the series\n       that matters, not because it is an alarm colour: colour never becomes a\n       field in this system. Approved is folded into Open, since three fills is\n       all a neutral ramp can separate. -->\n  <ul x-ref=\"legend\" class=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]/4 text-zinc-600\">\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-200\" aria-hidden=\"true\"></span>Closed\n    </li>\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n    </li>\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-800\" aria-hidden=\"true\"></span>Overdue\n    </li>\n  </ul>\n\n  <div class=\"mt-4 h-64\">\n    <canvas x-ref=\"canvas\" role=\"img\"\n            aria-label=\"Orders by status, April to August. Volume grows from 92 to 128 a month, and overdue orders peak at 9 in July before falling to 7.\"></canvas>\n  </div>\n\n  <div class=\"sr-only\">\n    <table>\n      <caption>Orders by status, April to August 2026</caption>\n      <thead><tr><th scope=\"col\">Month</th><th scope=\"col\">Closed</th><th scope=\"col\">Open</th><th scope=\"col\">Overdue</th><th scope=\"col\">Total</th></tr></thead>\n      <tbody>\n        <tr><th scope=\"row\">April</th><td>53</td><td>35</td><td>4</td><td>92</td></tr>\n        <tr><th scope=\"row\">May</th><td>57</td><td>41</td><td>6</td><td>104</td></tr>\n        <tr><th scope=\"row\">June</th><td>66</td><td>47</td><td>5</td><td>118</td></tr>\n        <tr><th scope=\"row\">July</th><td>61</td><td>41</td><td>9</td><td>111</td></tr>\n        <tr><th scope=\"row\">August</th><td>72</td><td>49</td><td>7</td><td>128</td></tr>\n      </tbody>\n    </table>\n  </div>\n</div>"
        },
        {
          "id": "donut",
          "name": "Donut",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         const ring = getComputedStyle(this.$root).backgroundColor;\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'doughnut',\n           data: {\n             labels: ['Closed', 'Open', 'Overdue'],\n             datasets: [{ data: [72, 49, 7], backgroundColor: tone, borderColor: ring, borderWidth: 2 }]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false, cutout: '68%',\n             plugins: { tooltip: { callbacks: { label: c => c.label + ': ' + c.parsed + ' orders' } } }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <h3 class=\"text-[14px]/5 font-semibold\">Open order book</h3>\n  <p class=\"mt-0.5 text-[12px]/4 tabular-nums text-zinc-500\">As at 20 Aug 2026</p>\n\n  <div class=\"mt-4 flex flex-col items-center gap-5 sm:flex-row sm:gap-6\">\n    <div class=\"relative h-40 w-40 shrink-0\">\n      <canvas x-ref=\"canvas\" role=\"img\"\n              aria-label=\"Open order book of 128 orders: 72 closed, 49 open and 7 overdue.\"></canvas>\n      <!-- the total is HTML over the canvas, not painted into it, so it is text -->\n      <div class=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center\">\n        <span class=\"text-[20px]/7 font-semibold tabular-nums\">128</span>\n        <span class=\"text-[11px]/4 text-zinc-500\">orders</span>\n      </div>\n    </div>\n\n    <!-- the legend carries the values, so this chart needs no sr-only table -->\n    <ul x-ref=\"legend\" class=\"w-full min-w-0 flex-1 divide-y divide-zinc-100 text-[13px]/5 text-zinc-600\">\n      <li class=\"flex items-center justify-between gap-3 py-1.5\">\n        <span class=\"flex min-w-0 items-center gap-2\">\n          <span data-series class=\"size-2 shrink-0 rounded-full bg-zinc-200\" aria-hidden=\"true\"></span>\n          <span class=\"truncate\">Closed</span>\n        </span>\n        <span class=\"shrink-0 font-medium tabular-nums text-zinc-900\">72</span>\n      </li>\n      <li class=\"flex items-center justify-between gap-3 py-1.5\">\n        <span class=\"flex min-w-0 items-center gap-2\">\n          <span data-series class=\"size-2 shrink-0 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>\n          <span class=\"truncate\">Open</span>\n        </span>\n        <span class=\"shrink-0 font-medium tabular-nums text-zinc-900\">49</span>\n      </li>\n      <li class=\"flex items-center justify-between gap-3 py-1.5\">\n        <span class=\"flex min-w-0 items-center gap-2\">\n          <span data-series class=\"size-2 shrink-0 rounded-full bg-zinc-800\" aria-hidden=\"true\"></span>\n          <span class=\"truncate\">Overdue</span>\n        </span>\n        <span class=\"shrink-0 font-medium tabular-nums text-zinc-900\">7</span>\n      </li>\n    </ul>\n  </div>\n</div>"
        },
        {
          "id": "sparkline",
          "name": "Sparkline",
          "html": "<div class=\"max-w-xs rounded-xl border border-zinc-200 bg-white p-4\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         getComputedStyle(this.$refs.tone).backgroundColor !== 'rgba(0, 0, 0, 0)'\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const tone = getComputedStyle(this.$refs.tone).backgroundColor;\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'line',\n           data: {\n             labels: ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],\n             datasets: [{ data: [124, 138, 119, 156, 141, 168, 203, 187, 196, 224, 231, 241],\n                          borderColor: tone, borderWidth: 2, pointRadius: 0, tension: 0.35, fill: false }]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false,\n             scales: { x: { display: false }, y: { display: false } },\n             plugins: { tooltip: { enabled: false } }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <!-- a sparkline has no legend, so the palette is a hidden swatch. It has to be a\n       real class attribute: Tailwind never sees a class name written inside x-init. -->\n  <span x-ref=\"tone\" class=\"hidden bg-zinc-800\" aria-hidden=\"true\"></span>\n\n  <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Committed value</p>\n  <div class=\"mt-1.5 flex items-end justify-between gap-3\">\n    <p class=\"text-[24px]/8 font-semibold tabular-nums\">₹2.41 Cr</p>\n    <p class=\"pb-1 text-[12px]/4 tabular-nums text-zinc-600\">+14% YoY</p>\n  </div>\n\n  <div class=\"mt-3 h-10\">\n    <canvas x-ref=\"canvas\" role=\"img\"\n            aria-label=\"Committed value over the last twelve months, rising from ₹1.24 crore to ₹2.41 crore, with the steepest climb in March.\"></canvas>\n  </div>\n</div>"
        },
        {
          "id": "empty",
          "name": "Empty",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-5\">\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Committed value by month</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Silvassa plant · 2026</p>\n  </div>\n\n  <!-- no data means no axes. An empty grid reads as a chart that failed to load. -->\n  <div class=\"mt-4 flex h-64 flex-col items-center justify-center rounded-lg border border-dashed border-zinc-200 px-6 text-center\">\n    <span class=\"flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n      <i data-lucide=\"chart-line\" class=\"size-4 text-zinc-600\"></i>\n    </span>\n    <p class=\"mt-3 text-[14px]/5 font-medium\">Nothing committed yet this year</p>\n    <p class=\"mt-1 max-w-sm text-[13px]/5 text-zinc-600\">\n      The first approved order will plot here. Draft orders are not counted.\n    </p>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django template",
          "html": "{# json_script escapes the payload and puts it in a script tag the browser will\n   not execute. Never interpolate JSON into an attribute: one apostrophe in a\n   supplier name ends the attribute and takes the page with it. #}\n{{ spend_by_month|json_script:\"spend-by-month\" }}\n\n<div class=\"rounded-xl border border-zinc-200 bg-white p-5\"\n     x-data=\"{\n       chart: null,\n       init() { this.wait(() => this.draw()); },\n       /* Tailwind's browser build compiles after first paint, so a class can\n          still be uncompiled when Alpine initialises and getComputedStyle\n          hands back transparent. Check every swatch, not the first: they do\n          not all land in the same pass. With a compiled stylesheet this\n          passes on the first frame and costs nothing. */\n       wait(fn) {\n         const probes = Array.from(this.$refs.legend.querySelectorAll('[data-series]'));\n         probes.every(el => getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)')\n           ? fn()\n           : requestAnimationFrame(() => this.wait(fn));\n       },\n       draw() {\n         Chart.defaults.font.family = getComputedStyle(this.$root).fontFamily;\n         const rows = JSON.parse(document.getElementById('spend-by-month').textContent);\n         const grid = getComputedStyle(this.$root).borderTopColor;\n         const ink  = getComputedStyle(this.$refs.legend).color;\n         const tone = Array.from(this.$refs.legend.querySelectorAll('[data-series]'))\n                           .map(el => getComputedStyle(el).backgroundColor);\n         this.chart = new Chart(this.$refs.canvas, {\n           type: 'line',\n           data: {\n             labels: rows.map(r => r.month),\n             datasets: [{\n               label: 'Committed value', data: rows.map(r => r.value),\n               borderColor: tone[0], backgroundColor: tone[0],\n               borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3\n             }]\n           },\n           options: {\n             responsive: true, maintainAspectRatio: false,\n             interaction: { mode: 'index', intersect: false },\n             scales: {\n               x: { grid: { display: false }, border: { color: grid }, ticks: { color: ink } },\n               y: { beginAtZero: true, grid: { color: grid }, border: { display: false },\n                    ticks: { color: ink, padding: 8, callback: v => (v / 100000).toFixed(1).replace('.0', '') + 'L' } }\n             },\n             plugins: { tooltip: { callbacks: { label: c => '₹' + c.parsed.y.toLocaleString('en-IN') } } }\n           }\n         });\n       },\n       destroy() { this.chart && this.chart.destroy(); }\n     }\">\n\n  <div class=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Committed value by month</h3>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">{{ plant.name }} · {{ year }}</p>\n  </div>\n\n  <ul x-ref=\"legend\" class=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]/4 text-zinc-600\">\n    <li class=\"flex items-center gap-1.5\">\n      <span data-series class=\"size-2 rounded-full bg-zinc-800\" aria-hidden=\"true\"></span>Committed value\n    </li>\n  </ul>\n\n  <div class=\"mt-4 h-64\">\n    <canvas x-ref=\"canvas\" role=\"img\" aria-label=\"{{ chart_summary }}\"></canvas>\n  </div>\n\n  <div class=\"sr-only\">\n    <table>\n      <caption>Committed value by month, {{ plant.name }}, {{ year }}</caption>\n      <thead><tr><th scope=\"col\">Month</th><th scope=\"col\">Committed value</th></tr></thead>\n      <tbody>\n        {% for row in spend_by_month %}\n          <tr><th scope=\"row\">{{ row.month }}</th><td>₹{{ row.value|intcomma }}</td></tr>\n        {% endfor %}\n      </tbody>\n    </table>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "progress",
      "name": "Progress",
      "category": "data",
      "description": "How far along something is — receipt against an order, a step in a workflow, a fill level in a row.",
      "when_to_use": "A quantity that moves toward a known total. For work with no known end use a spinner instead.",
      "rules": [
        "Use role=\"progressbar\" with aria-valuenow / aria-valuemin / aria-valuemax so the number is announced.",
        "The bar is graphite by default. When it tracks a record that has a status, take the colour from the locked status mapping — an overdue order gets a red bar — never colour it to make it look livelier.",
        "The percentage text is not optional — a bar alone cannot be read precisely."
      ],
      "anatomy": [
        {
          "part": "Track",
          "description": "A 4px zinc-100 rail, rounded-full, that clips the fill."
        },
        {
          "part": "Fill",
          "description": "Graphite by default, width set as a percentage, with a transition so it moves rather than jumps."
        },
        {
          "part": "Label",
          "description": "The numbers behind the bar — \"7,800 of 12,000 kg\" — because a bar alone cannot be read precisely."
        },
        {
          "part": "Percentage",
          "description": "tabular-nums, so it does not shift the layout as it counts up."
        },
        {
          "part": "Segments",
          "description": "For a workflow with named stages, one block per stage rather than a continuous bar."
        }
      ],
      "behaviour": [
        "The fill transitions its width; it never animates from zero on every render, which would read as a reload.",
        "A bar tracking a record with a status takes its colour from the locked status mapping — an overdue order gets a red bar.",
        "Work with no known total gets a spinner, not a bar. A bar implies an end.",
        "Values over 100% clamp visually but the number stays truthful, so over-receipt is visible rather than hidden.",
        "The segmented form marks the current stage distinctly from both the done ones and the ones still to come."
      ],
      "accessibility": [
        "role=\"progressbar\" with aria-valuenow, aria-valuemin and aria-valuemax, so the value is announced.",
        "aria-label names what is progressing — \"Receipt against PO-24-1187\", not \"Progress\".",
        "The percentage is real text beside the bar, not conveyed by the fill width alone.",
        "The segmented form uses a list, so the number of stages is announced.",
        "Colour never carries the state by itself; the label says which stage the work is at."
      ],
      "related": [
        "stat-card",
        "spinner",
        "skeleton"
      ],
      "variants": [
        {
          "id": "bar",
          "name": "Bar with label",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <p class=\"text-[13px]/5 font-medium\">Receipt against PO-24-1187</p>\n    <p class=\"text-[13px]/5 tabular-nums\">68%</p>\n  </div>\n  <div class=\"mt-2 h-2 overflow-hidden rounded-full bg-zinc-100\"\n       role=\"progressbar\" aria-label=\"Receipt against PO-24-1187\" aria-valuenow=\"68\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n    <div class=\"h-full rounded-full bg-zinc-700\" style=\"width: 68%\"></div>\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500 tabular-nums\">₹12,52,560 received of ₹18,42,000 · Sharma Extrusions</p>\n</div>"
        },
        {
          "id": "steps",
          "name": "Segmented",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <p class=\"text-[13px]/5 font-medium\">Lines received</p>\n    <p class=\"text-[13px]/5 text-zinc-600 tabular-nums\">6 of 14</p>\n  </div>\n  <div class=\"mt-2 flex gap-1\" role=\"progressbar\" aria-label=\"Lines received\" aria-valuenow=\"6\" aria-valuemin=\"0\" aria-valuemax=\"14\">\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-700\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n    <span class=\"h-2 flex-1 rounded-full bg-zinc-100\"></span>\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Last GRN 12 Aug · Nashik Steel Traders</p>\n</div>"
        },
        {
          "id": "inline",
          "name": "Inline mini bar",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Received</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"flex items-center gap-2\">\n            <span class=\"h-1.5 w-24 overflow-hidden rounded-full bg-zinc-100\"\n                  role=\"progressbar\" aria-label=\"Received against PO-24-1187\" aria-valuenow=\"68\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n              <span class=\"block h-full rounded-full bg-zinc-700\" style=\"width: 68%\"></span>\n            </span>\n            <span class=\"text-[12px]/4 text-zinc-600 tabular-nums\">68%</span>\n          </span>\n        </td>\n      </tr>\n      <tr>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"flex items-center gap-2\">\n            <span class=\"h-1.5 w-24 overflow-hidden rounded-full bg-zinc-100\"\n                  role=\"progressbar\" aria-label=\"Received against PO-24-1194\" aria-valuenow=\"12\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n              <span class=\"block h-full rounded-full bg-red-600\" style=\"width: 12%\"></span>\n            </span>\n            <span class=\"text-[12px]/4 text-red-600 tabular-nums\">12%</span>\n          </span>\n        </td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        }
      ]
    },
    {
      "id": "empty-state",
      "name": "Empty state",
      "category": "data",
      "description": "What a list shows when it has nothing in it. Says why it is empty and what to do next.",
      "when_to_use": "Every list, table and search result. An empty table with only a header reads as a bug.",
      "rules": [
        "Nothing-yet and no-matches are different states and must not share one message. One offers a create action, the other offers to clear the filter.",
        "A load failure is not an empty state in disguise — say it failed and offer Retry, never \"No orders found\".",
        "Keep it inside the panel that would have held the rows, so the page does not reflow when data arrives."
      ],
      "anatomy": [
        {
          "part": "Icon",
          "description": "A single muted glyph. It sets the tone; it carries no information."
        },
        {
          "part": "Headline",
          "description": "What is true right now, in one sentence. \"No orders match these filters\", not \"No data\"."
        },
        {
          "part": "Explanation",
          "description": "Why it is empty, when that is not obvious from the headline."
        },
        {
          "part": "Action",
          "description": "The one thing to do next — create the first record, or clear the filter. Different per state."
        },
        {
          "part": "Container",
          "description": "The panel that would have held the rows, so the page does not reflow when data arrives."
        }
      ],
      "behaviour": [
        "Nothing-yet and no-matches are different states with different actions and must never share a message.",
        "A load failure is not an empty state. It says the load failed and offers Retry, never \"No orders found\".",
        "The action matches the cause: clear the filter when filtered, create a record when genuinely new.",
        "It occupies the same box the data would have, so arriving data does not shift the page under the cursor.",
        "A first-run empty state is worth more effort than any other, because it is the first thing a new user sees."
      ],
      "accessibility": [
        "The headline is a real heading at the level the surrounding page implies.",
        "The icon is aria-hidden — it is decoration and repeating it adds nothing.",
        "The action is a button or link, reachable by keyboard, not a clickable div.",
        "When the empty state replaces a table after a filter, the change is announced rather than only rendered.",
        "The error variant says what failed in words, since a red icon alone is not a message."
      ],
      "related": [
        "table",
        "skeleton",
        "alert"
      ],
      "variants": [
        {
          "id": "no-results",
          "name": "No results",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white px-6 py-12 text-center\">\n  <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"search-x\" class=\"size-5 text-zinc-600\"></i>\n  </span>\n  <p class=\"mt-3 text-[16px]/6 font-semibold\">No orders match these filters</p>\n  <p class=\"mx-auto mt-1 max-w-sm text-[13px]/5 text-zinc-600\">Vendor is Gujarat Polymers Ltd, status is Overdue and the date range is 01–12 Aug. Widen one of them.</p>\n  <button class=\"mt-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Clear filters</button>\n</div>"
        },
        {
          "id": "nothing-yet",
          "name": "Nothing created yet",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white px-6 py-12 text-center\">\n  <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"file-text\" class=\"size-5 text-zinc-600\"></i>\n  </span>\n  <p class=\"mt-3 text-[16px]/6 font-semibold\">No purchase orders yet</p>\n  <p class=\"mx-auto mt-1 max-w-sm text-[13px]/5 text-zinc-600\">Orders raised for Fabrication, Compounding, Dispatch, Maintenance and Tooling will appear here.</p>\n  <button class=\"mx-auto mt-4 flex items-center gap-2 rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n    <i data-lucide=\"plus\" class=\"size-4\"></i>New purchase order\n  </button>\n</div>"
        },
        {
          "id": "error",
          "name": "Error loading",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white px-6 py-12 text-center\">\n  <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"alert-circle\" class=\"size-5 text-red-600\"></i>\n  </span>\n  <p class=\"mt-3 text-[16px]/6 font-semibold\">Could not load the order register</p>\n  <p class=\"mx-auto mt-1 max-w-sm text-[13px]/5 text-zinc-600\">The request timed out after 30 seconds. Nothing was changed.</p>\n  <div class=\"mt-4 flex flex-wrap items-center justify-center gap-2\">\n    <button class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n      <i data-lucide=\"rotate-cw\" class=\"size-4\"></i>Retry\n    </button>\n    <a href=\"#\" class=\"text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">Report this</a>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "skeleton",
      "name": "Skeleton",
      "category": "data",
      "description": "Grey blocks in the shape of the content that is loading.",
      "when_to_use": "A load you expect to take more than about 300ms and whose layout you already know. For a short or unknown-shape load, use a spinner.",
      "rules": [
        "The skeleton must match the real layout — same number of rows, same column widths — or the page jumps when data lands.",
        "Mark the container aria-busy=\"true\" and aria-hidden the blocks, so a screen reader is not read a wall of nothing.",
        "animate-pulse only. A custom shimmer keyframe would not survive being pasted into another page."
      ],
      "anatomy": [
        {
          "part": "Block",
          "description": "A zinc-100 rectangle at the size of the thing it stands in for, with animate-pulse."
        },
        {
          "part": "Row group",
          "description": "Blocks arranged in the real layout — same row count, same column widths."
        },
        {
          "part": "Container",
          "description": "Marked aria-busy while loading, so the state is known and not merely drawn."
        },
        {
          "part": "Variation",
          "description": "Slightly different widths on text lines, because a stack of identical bars does not read as text."
        }
      ],
      "behaviour": [
        "The skeleton matches the real layout exactly, or the page jumps when the data lands.",
        "It appears only for loads expected to run past about 300ms; below that it is a flash of noise.",
        "It is replaced by content, never faded into it — a cross-fade makes the arrival harder to notice, not easier.",
        "animate-pulse only. A custom shimmer keyframe would not survive being pasted into another page.",
        "For a load whose shape is unknown, a spinner is honest and a skeleton is a guess."
      ],
      "accessibility": [
        "The container carries aria-busy=\"true\" so the wait is announced.",
        "The blocks themselves are aria-hidden, so a screen reader is not read a wall of nothing.",
        "Real content replaces the skeleton in the same container, so focus position survives the swap.",
        "Nothing in the skeleton is focusable — a Tab landing on a grey rectangle is a dead end.",
        "The animation respects prefers-reduced-motion, since a pulsing page is a problem for some readers."
      ],
      "related": [
        "empty-state",
        "spinner",
        "progress"
      ],
      "variants": [
        {
          "id": "text",
          "name": "Text lines",
          "html": "<div class=\"animate-pulse rounded-xl border border-zinc-200 bg-white p-4\" aria-busy=\"true\" aria-label=\"Loading order details\">\n  <div class=\"h-3 w-32 rounded bg-zinc-200\"></div>\n  <div class=\"mt-3 h-2.5 w-full rounded bg-zinc-200\"></div>\n  <div class=\"mt-2 h-2.5 w-full rounded bg-zinc-200\"></div>\n  <div class=\"mt-2 h-2.5 w-2/3 rounded bg-zinc-200\"></div>\n</div>"
        },
        {
          "id": "table",
          "name": "Table rows",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\" aria-busy=\"true\" aria-label=\"Loading order register\">\n  <div class=\"animate-pulse\">\n    <div class=\"flex items-center gap-4 border-b border-zinc-200 px-4 py-2.5\">\n      <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      <div class=\"h-2.5 w-32 rounded bg-zinc-200\"></div>\n      <div class=\"ml-auto h-2.5 w-20 rounded bg-zinc-200\"></div>\n    </div>\n    <div class=\"flex items-center gap-4 border-b border-zinc-100 px-4 py-3\">\n      <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      <div class=\"h-2.5 w-40 rounded bg-zinc-200\"></div>\n      <div class=\"ml-auto h-2.5 w-20 rounded bg-zinc-200\"></div>\n    </div>\n    <div class=\"flex items-center gap-4 border-b border-zinc-100 px-4 py-3\">\n      <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      <div class=\"h-2.5 w-28 rounded bg-zinc-200\"></div>\n      <div class=\"ml-auto h-2.5 w-20 rounded bg-zinc-200\"></div>\n    </div>\n    <div class=\"flex items-center gap-4 border-b border-zinc-100 px-4 py-3\">\n      <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      <div class=\"h-2.5 w-36 rounded bg-zinc-200\"></div>\n      <div class=\"ml-auto h-2.5 w-20 rounded bg-zinc-200\"></div>\n    </div>\n    <div class=\"flex items-center gap-4 px-4 py-3\">\n      <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      <div class=\"h-2.5 w-32 rounded bg-zinc-200\"></div>\n      <div class=\"ml-auto h-2.5 w-20 rounded bg-zinc-200\"></div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "card",
          "name": "Card",
          "html": "<div class=\"animate-pulse rounded-xl border border-zinc-200 bg-white p-4\" aria-busy=\"true\" aria-label=\"Loading vendor summary\">\n  <div class=\"flex items-center gap-3\">\n    <div class=\"size-10 rounded-full bg-zinc-200\"></div>\n    <div class=\"flex-1\">\n      <div class=\"h-3 w-40 rounded bg-zinc-200\"></div>\n      <div class=\"mt-2 h-2.5 w-24 rounded bg-zinc-200\"></div>\n    </div>\n  </div>\n  <div class=\"mt-4 h-7 w-36 rounded bg-zinc-200\"></div>\n  <div class=\"mt-4 flex gap-2\">\n    <div class=\"h-8 w-24 rounded-lg bg-zinc-200\"></div>\n    <div class=\"h-8 w-20 rounded-lg bg-zinc-200\"></div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "spinner",
      "name": "Spinner",
      "category": "data",
      "description": "The indeterminate wait. A graphite ring that turns while work of unknown length is in flight, and a line of text that says what the work is.",
      "when_to_use": "Work whose length you cannot predict and whose answer you cannot draw — a report the server is still assembling, a save you are waiting on, a filter that may return four rows or four thousand. It is the wrong answer twice over: if you know the total, a progress bar can say how far along it is, and if you know the layout coming back, a skeleton can hold its shape so the page does not jump. A spinner over a register you have rendered a thousand times is a skeleton nobody wrote.",
      "rules": [
        "The ring is borders, not a Lucide icon. Measured: loader-circle is a 2-unit stroke on a 24-unit viewBox, so it paints 1.33px inside size-4 and 3.33px inside size-10 — the same glyph two and a half times heavier — and a size scale built on it thickens as it grows, while a border width is chosen per size. The second reason matters more. An <i data-lucide> is an empty inline element with no box at all until createIcons() has run over it, and a spinner arriving inside an htmx swap is exactly the case that cannot count on that.",
        "The ring is aria-hidden and the words are the announcement. A turning shape resolves to nothing worth reading, so a spinner that is only a shape is a wait nobody was told about. Real text goes in a role=\"status\" beside it — sr-only where the panel already names what is loading, visible where it does not.",
        "aria-label on a role=\"status\" is a name, not content, and a live region announces content. Resolved in the accessibility tree, a role=\"status\" carrying aria-label=\"Loading orders\" and no children comes back as a status named \"Loading orders\" with nothing inside it: it appears, nothing within it changed, and nothing is said. The same string as a text node comes back as status: Loading orders, which is the part that gets read.",
        "The live region has to be in the document before the text lands, the same way the combobox match count is. A role=\"status\" that arrives inside an htmx fragment with its message already in it never changed. Put the status on the panel that survives the swap and let the fragment replace only the rows under it.",
        "Never motion-reduce:animate-none on a spinner. Measured under prefers-reduced-motion: reduce, animation-name computes to none and the ring stops as a zinc-200 circle with one graphite quarter — a static broken ring that reads as a rendering fault, and the only sign that anything is still happening is gone. A 20px ring turning once a second is not the large-area motion the preference exists for. The accommodation is the label beside it, which is there for everybody.",
        "Graphite, always. Colour means data state and a wait has no state yet, so a green ring claims success before the server has answered and an amber one raises a warning nobody filed. There is no success spinner and no danger spinner. There is a spinner, and then there is what it resolved into.",
        "Keep the zinc-200 track. It is one step off white and one step off the zinc-100 page, so the circle is visible on both, and it is what makes the graphite quarter read as a position on a ring rather than a stray mark. Three transparent sides leave a bare arc that at 16px looks like a comma somebody typed by accident.",
        "Size and stroke move together: size-4 with border-2, size-5 with border-2, size-8 with border-[3px] — measured 16, 20 and 32px carrying 2, 2 and 3px. border-4 inside size-8 is a ring an eighth of its own diameter and reads as a donut chart rather than a spinner.",
        "The ring is shrink-0 and the label is what wraps. Measured at 390px beside a two-line sentence: a size-8 ring came back 32.0px wide with shrink-0 and 24.5px without it, and a size-4 ring 16.0px against 12.9px. Neither is a smaller spinner — height is untouched, so what you get is an ellipse turning on its long axis. Flex takes width out of whatever will give it, and a circle will.",
        "A spinner never replaces content that is already on screen. Swapping a rendered table for a centred ring throws away the scroll position and the row somebody was reading, and the panel collapses to the height of a ring and a caption, so everything below it jumps up under the cursor. Cover it instead: a bg-white/70 scrim with the ring on top and the rows still legible underneath. Measured across a full idle-busy-idle cycle of the overlay variant, the panel held 218px at every step.",
        "Covering content is not blocking it. A scrim stops the mouse and nothing else — Tab still walks into the stale rows underneath and Enter still fires a row action against data that is being replaced. The content wrapper takes :inert=\"busy\". Verified in Alpine 3.16: inert is on the boolean-attribute list, so a false value removes the attribute rather than writing inert=\"false\", which is a truthy string and would leave the region permanently inert.",
        "aria-busy belongs to the region that is waiting, not to the ring. The ring is what is drawn; the panel is what is busy. Alpine preserves only aria-pressed, aria-checked, aria-expanded and aria-selected when they are false, so :aria-busy=\"false\" removes the attribute outright — correct here, because aria-busy defaults to false, but not a thing to assume of any other aria-* binding.",
        "Below about 300ms a spinner is noise. The request answers before the eye has resolved the shape and all anyone sees is a flicker where the number was. Give every spinner a 500ms floor and most of them never paint at all.",
        "The floor is opacity, not x-show. opacity-0 keeps the box, so nothing moves when the ring arrives; display does not, and a spinner that appears 500ms after the click widens the row and takes the button out from under the cursor at exactly the moment somebody is going for a second one.",
        "The delay belongs in the bound class, not the base class. Left in the base it delays the fade out as well — measured, a spinner written opacity-0 transition-opacity delay-500 with :class=\"busy && 'opacity-100'\" was still at full opacity 520ms after a 1.6s request had already landed its content, which reads as a panel still loading something it has finished drawing. Base delay-0, bound opacity-100 delay-500, and the same spinner was gone 196ms after the work stopped.",
        "The CSS floor cannot reach the live region. x-text fires the instant the flag flips, so a 120ms request that never painted a ring is still announced as a wait. Holding the announcement back needs a real 500ms timer alongside the class binding.",
        "Do not rebuild a button's busy state here. The button entry already holds the label width with a grid overlay so the row cannot reflow mid-click, and keeps the disabled attribute alongside aria-busy so a second click cannot post twice. Inside a button the loader also stays a Lucide icon at size-4: the button owns an icon slot already, and at 16px nobody sees the difference between a 1.33px svg stroke and a 2px border. Above 16px, the ring.",
        "One spinner per region, at the region's root. Four rings turning on one screen do not say that four things are loading, they say the page is broken. If the whole screen is waiting, spin the panel that matters and leave the rest alone.",
        "Never cover the whole app shell. A spinner over the topbar and the sidebar takes away the navigation somebody could have used to leave a request that is not coming back. Scope it to the panel doing the work.",
        "A spinner has to resolve. Every one needs a branch that replaces it when the request fails — the error variant of empty-state is what goes in its place. A ring still turning at forty seconds is indistinguishable from a hung page, and the only move left is a reload, which on a form means posting it twice.",
        "Past about ten seconds, say something. The second line goes inside the role=\"status\" that is already there, so the change is what gets announced; a new region beside it is a second thing announcing itself while the first still has nothing new to say.",
        "The label names the work, not the fact of waiting. \"Loading\" is the message the ring already carries. \"Loading purchase orders\" is what tells somebody which of the four panels on the screen is the one holding them up.",
        "x-cloak on any spinner hidden at first paint. This is the one component where the flash reads as real: a ring that shows for a frame or two on every page load looks like a load that failed and retried, not like Alpine booting.",
        "Do not reach for htmx's .htmx-indicator class to get the floor. htmx injects its own stylesheet, and .htmx-request .htmx-indicator sets the transition shorthand at two classes of specificity, which resets transition-delay to zero. Measured on an element carrying both delay-500 and duration-150: computed transition-delay came back 0s and duration 0.2s — htmx's values, not Tailwind's. The floor vanishes silently and the indicator flashes on every fast request."
      ],
      "anatomy": [
        {
          "part": "Ring",
          "description": "size-5 rounded-full border-2 border-zinc-200 border-t-zinc-700 animate-spin. The track is the whole circle in zinc-200; border-t-zinc-700 repaints one quarter of it graphite, and animate-spin turns the box at 1s linear. Always shrink-0, always aria-hidden."
        },
        {
          "part": "Label",
          "description": "Real text naming the work — \"Loading purchase orders\", not \"Loading\". Visible where the panel does not already say it, sr-only where it does."
        },
        {
          "part": "Status region",
          "description": "The role=\"status\" the label lives in, in the document before the wait begins, on the element that survives the swap. This is the announcement; the ring contributes nothing to it."
        },
        {
          "part": "Busy region",
          "description": "The panel carrying aria-busy=\"true\" while it waits, and :inert=\"busy\" on its content while a scrim covers it."
        },
        {
          "part": "Slot",
          "description": "The fixed box the spinner occupies. opacity-0 with the delay in the bound class, never display, so a 500ms floor costs no layout movement."
        },
        {
          "part": "Scrim",
          "description": "bg-white/70 over content that stays on screen through a refresh, so the rows underneath read as stale rather than gone."
        },
        {
          "part": "Escalation",
          "description": "The second line that appears past about ten seconds, inside the same status region, saying why this one is slow."
        }
      ],
      "behaviour": [
        "It is graphite whatever it is waiting for. Colour describes what a record is doing, and a request in flight has not done anything yet.",
        "It appears at 500ms, not at zero. Most requests answer first and the ring is never painted, which is the point — the floor is what stops a register flickering on every filter change.",
        "It holds its box while it is invisible, so the row does not widen when it arrives and does not narrow when it goes.",
        "It resolves. Content replaces it, or an error replaces it, and past about ten seconds it grows a line saying why it is taking so long. It does not turn forever.",
        "Content already on screen is covered, not replaced, and the covered content goes inert so the keyboard cannot reach rows that are being swapped out.",
        "One per region. A screen with four rings on it reads as broken rather than busy.",
        "The animation keeps running under prefers-reduced-motion, because stopping it removes the only signal. The label beside it is what carries the state without motion.",
        "Inside a button, the button's own busy state does the work — the ring starts at size-5 and above, where a button's size-4 icon slot has run out."
      ],
      "accessibility": [
        "The ring is aria-hidden=\"true\". It is a shape, and a shape announces nothing worth hearing.",
        "The announcement is the text content of a role=\"status\", never an aria-label on the ring — a live region reports what changed inside it, and a name is not content.",
        "The status region exists before the message does. A region that arrives with its text already in it has nothing to announce.",
        "aria-busy=\"true\" sits on the region that is waiting, so the state is known and not merely drawn.",
        "Content under a scrim takes inert, so Tab cannot walk into rows that are about to be replaced and Enter cannot fire an action against them.",
        "The animation is not disabled under prefers-reduced-motion, because a frozen ring is a spinner that has stopped saying anything. The visible or sr-only label is the non-motion signal.",
        "The ten-second escalation is written into the same status region, so the change is announced rather than only rendered.",
        "Nothing inside a spinner is focusable. A Tab landing on a turning ring is a dead end, and there is no action there to take."
      ],
      "related": [
        "skeleton",
        "progress",
        "button"
      ],
      "variants": [
        {
          "id": "default",
          "name": "The spinner",
          "html": "<!-- The whole component. A zinc-200 track with one quarter repainted graphite by\n     border-t-zinc-700, turned by animate-spin at 1s linear.\n\n     It is borders and not a Lucide loader on purpose. A lucide glyph is a 2-unit\n     stroke on a 24-unit viewBox, so it paints 1.33px inside size-4 and 3.33px\n     inside size-10 — a size scale built on it grows heavier as it grows — and an\n     <i data-lucide> has no box at all until createIcons() has run over it, which\n     is exactly what a spinner arriving in an htmx swap cannot count on.\n\n     The ring is aria-hidden: a turning shape resolves to nothing worth reading.\n     What gets announced is the text inside the role=\"status\", and it is text\n     content rather than an aria-label because a live region reports what changed\n     inside it and a name is not content. Here it is sr-only, because the panel\n     around it already says what is loading. -->\n<div class=\"flex items-center gap-3\">\n  <span class=\"size-5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n  <p role=\"status\" class=\"sr-only\">Loading purchase orders</p>\n</div>"
        },
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- Three, and the stroke moves with the box, because the stroke is what keeps\n     them looking like one object. 16/2, 20/2 and 32/3 sit between one eleventh\n     and one eighth of the diameter; border-4 inside size-8 is one eighth at four\n     times the area and reads as a donut chart.\n\n     Each ring here is aria-hidden with no status beside it, because this is a\n     picture of three sizes rather than three things loading. In use, every one of\n     them carries its own label. -->\n<div class=\"flex flex-wrap items-end gap-x-10 gap-y-6\">\n  <div class=\"flex flex-col items-center gap-2.5\">\n    <span class=\"flex h-8 items-center\">\n      <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    </span>\n    <span class=\"text-[12px]/4 text-zinc-500\">size-4 · in a row or beside a control</span>\n  </div>\n  <div class=\"flex flex-col items-center gap-2.5\">\n    <span class=\"flex h-8 items-center\">\n      <span class=\"size-5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    </span>\n    <span class=\"text-[12px]/4 text-zinc-500\">size-5 · the default, beside a label</span>\n  </div>\n  <div class=\"flex flex-col items-center gap-2.5\">\n    <span class=\"flex h-8 items-center\">\n      <span class=\"size-8 shrink-0 animate-spin rounded-full border-[3px] border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    </span>\n    <span class=\"text-[12px]/4 text-zinc-500\">size-8 · centred in a panel</span>\n  </div>\n</div>"
        },
        {
          "id": "label",
          "name": "With a label",
          "html": "<!-- With a visible label the role=\"status\" moves to the row and the label is its\n     content. The ring stays aria-hidden and contributes nothing, and there is no\n     sr-only copy underneath — the region would then hold both strings and the\n     wait would be read out twice.\n\n     Do not name the ring with aria-label instead. Resolved, a role=\"status\" with\n     aria-label and no children is a status *named* \"Loading orders\" whose content\n     is empty: it appears, nothing inside it changed, and nothing is said.\n\n     The label names the work. \"Loading\" is the message the ring already carries;\n     \"Loading purchase orders\" says which of the four panels on the screen is the\n     one holding somebody up.\n\n     The ring is shrink-0 and the paragraph is what wraps. Without it, flex takes\n     the width out of the circle instead of the sentence — measured at 390px, the\n     size-4 ring in the second row came back 12.9px wide against 16px of height,\n     which is not a smaller spinner but an ellipse. -->\n<div class=\"space-y-6\">\n  <div class=\"flex items-center gap-3\" role=\"status\">\n    <span class=\"size-5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    <span class=\"text-[13px]/5 text-zinc-600\">Loading purchase orders</span>\n  </div>\n\n  <div class=\"flex items-start gap-3\" role=\"status\">\n    <span class=\"mt-0.5 size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    <p class=\"text-[13px]/5 text-zinc-600\">Assembling the GRN reconciliation for 01 Apr to 12 Aug. That is 14 months of receipts across five plants, so it takes a while.</p>\n  </div>\n</div>"
        },
        {
          "id": "panel",
          "name": "Centred in a panel",
          "html": "<!-- A region with nothing in it yet. The box is already the height the loaded\n     panel will be, so the rows land without the page shuffling itself — a\n     spinner in a box that collapses to its content is why a dashboard reflows\n     twice on every load.\n\n     aria-busy is on the panel. The panel is the thing that is waiting; the ring\n     is only what is drawn. -->\n<div class=\"flex min-h-64 items-center justify-center rounded-xl border border-zinc-200 bg-white p-6\" aria-busy=\"true\">\n  <div class=\"flex flex-col items-center gap-3 text-center\">\n    <span class=\"size-8 shrink-0 animate-spin rounded-full border-[3px] border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    <p role=\"status\" class=\"text-[13px]/5 text-zinc-600\">Loading the order register</p>\n  </div>\n</div>"
        },
        {
          "id": "inline",
          "name": "Beside a control",
          "html": "<!-- size-4 next to anything control-height, because a 20px ring beside a 36px\n     input is louder than the input.\n\n     None of these is a button, and that is deliberate: a button's busy state is\n     already specified in the button entry, which holds the label width with a\n     grid overlay so the row cannot reflow mid-click and keeps the disabled\n     attribute alongside aria-busy so a second click cannot post twice. Copy that\n     one rather than dropping a bare ring into a <button>.\n\n     Each of the three has its own role=\"status\", because each names a different\n     piece of work. One region for the page would have them overwriting each\n     other, and the last one to finish would be the only one ever announced. -->\n<div class=\"max-w-md space-y-6 rounded-xl border border-zinc-200 bg-white p-4\">\n\n  <!-- inside the field's ring, where the input entry puts its icons -->\n  <div>\n    <label for=\"sp-gstin\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor GSTIN</label>\n    <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n      <input id=\"sp-gstin\" value=\"27AABCS1429B1ZX\" class=\"w-full min-w-0 bg-transparent px-3 py-2 font-mono text-[13px]/5 outline-none\">\n      <span class=\"mr-3 size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    </div>\n    <p role=\"status\" class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Checking this GSTIN against the GST portal</p>\n  </div>\n\n  <!-- in a cell, where the figure will be. Right-aligned into the same column\n       the number lands in, so the row does not shift when it arrives. -->\n  <div class=\"border-t border-zinc-100 pt-4\">\n    <div class=\"flex items-baseline justify-between gap-4\">\n      <span class=\"text-[13px]/5 text-zinc-600\">Committed value, all plants</span>\n      <span class=\"flex h-5 items-center\">\n        <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      </span>\n    </div>\n    <p role=\"status\" class=\"mt-1 text-[12px]/4 text-zinc-500\">Recalculating committed value</p>\n  </div>\n\n  <!-- a background refresh of something already shown. The stale figure stays\n       legible; the ring says a newer one is on its way. -->\n  <div class=\"border-t border-zinc-100 pt-4\">\n    <p class=\"text-[20px]/7 font-semibold tracking-tight tabular-nums\">1,842</p>\n    <div class=\"mt-1 flex items-center gap-2\">\n      <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      <span role=\"status\" class=\"text-[12px]/4 text-zinc-500\">Refreshing open orders, last read 11:04</span>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "overlay",
          "name": "Over content already on screen",
          "html": "<!-- A refresh of rows somebody is already reading. Replacing them with a centred\n     ring would throw away the scroll position and collapse the panel, so the rows\n     stay and a bg-white/70 scrim goes over them.\n\n     The scrim stops the mouse and nothing else, which is why the content wrapper\n     takes :inert=\"busy\" — without it Tab walks straight into rows that are being\n     replaced and Enter fires their actions against data on the way out. inert is\n     a boolean attribute Alpine knows about, so a false value removes it rather\n     than writing inert=\"false\", which is a truthy string.\n\n     aria-busy is on the panel, and the sr-only status is outside the scrim so it\n     stays in the document across the whole cycle and reports both ends of it.\n\n     busy starts true so the scrim is already there at first paint rather than\n     appearing over rows somebody has begun reading, and x-init runs the cycle\n     once so it resolves. In an application the swap is what clears it. -->\n<div class=\"relative overflow-hidden rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{ busy: true, run() { this.busy = true; setTimeout(() => this.busy = false, 2400) } }\"\n     x-init=\"run()\"\n     :aria-busy=\"busy\">\n\n  <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3\">\n    <p class=\"text-[14px]/5 font-semibold\">Order register</p>\n    <button type=\"button\" @click=\"run()\" :disabled=\"busy\"\n            class=\"inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[12px]/4 font-medium hover:bg-zinc-100 disabled:text-zinc-400\">\n      <i data-lucide=\"rotate-cw\" class=\"size-3.5 text-zinc-600\"></i>Refresh\n    </button>\n  </div>\n\n  <div :inert=\"busy\">\n    <table class=\"w-full text-[13px]/5\">\n      <thead>\n        <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n          <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n          <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n          <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n        </tr>\n      </thead>\n      <tbody>\n        <tr class=\"border-b border-zinc-100\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n          <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n          <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n        </tr>\n        <tr class=\"border-b border-zinc-100\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n          <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n          <td class=\"px-4 py-2.5 text-right tabular-nums\">₹4,16,500</td>\n        </tr>\n        <tr>\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1203</td>\n          <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n          <td class=\"px-4 py-2.5 text-right tabular-nums\">₹9,07,250</td>\n        </tr>\n      </tbody>\n    </table>\n  </div>\n\n  <div x-show=\"busy\" x-cloak class=\"absolute inset-0 z-10 flex items-center justify-center bg-white/70 px-4\">\n    <span class=\"flex items-center gap-2.5 rounded-full border border-zinc-200 bg-white px-3.5 py-2 shadow-sm\">\n      <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      <span class=\"text-[12px]/4 font-medium text-zinc-600\">Refreshing 24 orders</span>\n    </span>\n  </div>\n\n  <p role=\"status\" class=\"sr-only\" x-text=\"busy ? 'Refreshing the order register' : 'Order register updated'\"></p>\n</div>"
        },
        {
          "id": "delayed",
          "name": "The 500ms floor",
          "html": "<!-- Both buttons run the same request; only one of them is slow enough to be\n     worth a spinner. Below about 300ms the ring answers before the eye has\n     resolved it and all anyone sees is a flicker where the number was.\n\n     The floor needs no timer. The ring sits at opacity-0 and picks up\n     transition-opacity delay-500 only while the flag is on, so the fade does not\n     begin until 500ms after the click. A request that answers in 120ms turns the\n     flag off again during the delay, the computed opacity goes from 0 to 0, and\n     nothing was ever painted. Measured: opacity never left 0 on the fast button\n     and reached 1 at 683ms on the slow one.\n\n     The delay is in the bound class, not the base class. Left in the base it\n     delays the fade *out* too — measured, the ring was still at full opacity\n     520ms after a 1.6s request had landed its content, which reads as a panel\n     still loading something it has finished drawing. Written this way it was\n     gone 196ms after the work stopped.\n\n     opacity and not x-show, because opacity keeps the box. A spinner that\n     appears with display widens the row 500ms after the click and moves the\n     button out from under the cursor.\n\n     The floor cannot reach the live region: x-text fires the moment the flag\n     flips, so the announcement gets its own 500ms timer. Without it a 120ms\n     request that never painted a ring is still announced as a wait. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white p-4\"\n     x-data=\"{\n       busy: false, late: false, timer: null,\n       run(ms) {\n         this.busy = true; this.late = false;\n         clearTimeout(this.timer);\n         this.timer = setTimeout(() => { this.late = this.busy }, 500);\n         setTimeout(() => { this.busy = false; this.late = false; clearTimeout(this.timer) }, ms);\n       }\n     }\">\n  <p class=\"text-[13px]/5 font-medium\">Recalculate committed value</p>\n  <p class=\"mt-1 text-[12px]/4 text-zinc-500\">One plant answers in 120ms and never shows a ring. All five take 1.6s and do.</p>\n\n  <div class=\"mt-3 flex flex-wrap items-center gap-3\">\n    <button type=\"button\" @click=\"run(120)\"\n            class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">Nashik only</button>\n    <button type=\"button\" @click=\"run(1600)\"\n            class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">All five plants</button>\n\n    <span class=\"flex items-center gap-2 opacity-0 transition-opacity delay-0 duration-150\"\n          :class=\"busy && 'opacity-100 delay-500'\">\n      <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      <span class=\"text-[13px]/5 text-zinc-600\">Recalculating</span>\n    </span>\n  </div>\n\n  <p role=\"status\" class=\"sr-only\" x-text=\"late ? 'Recalculating committed value' : ''\"></p>\n</div>"
        },
        {
          "id": "states",
          "name": "Running, slow, failed",
          "html": "<!-- A spinner has to resolve. These are the three ends of one wait.\n\n     Past about ten seconds the second line goes *inside* the role=\"status\" that\n     is already there, so what gets announced is the change. A second region\n     beside it would be a new thing announcing itself while the first still has\n     nothing new to say.\n\n     The failure is not a spinner in a different colour — there is no danger\n     spinner. The ring is gone and the error variant of empty-state is what\n     stands in its place, at the size the panel already was, because a ring still\n     turning at forty seconds is indistinguishable from a hung page. -->\n<div class=\"grid gap-4 sm:grid-cols-3\">\n\n  <div class=\"flex min-h-44 flex-col items-center justify-center gap-3 rounded-xl border border-zinc-200 bg-white p-5 text-center\" aria-busy=\"true\">\n    <span class=\"size-6 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    <p role=\"status\" class=\"text-[13px]/5 text-zinc-600\">Loading the order register</p>\n  </div>\n\n  <div class=\"flex min-h-44 flex-col items-center justify-center gap-3 rounded-xl border border-zinc-200 bg-white p-5 text-center\" aria-busy=\"true\">\n    <span class=\"size-6 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n    <div role=\"status\">\n      <p class=\"text-[13px]/5 text-zinc-600\">Loading the order register</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500\">Still working. 14 months of receipts is a wide range.</p>\n    </div>\n  </div>\n\n  <div class=\"flex min-h-44 flex-col items-center justify-center gap-2 rounded-xl border border-zinc-200 bg-white p-5 text-center\">\n    <span class=\"flex size-8 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n      <i data-lucide=\"alert-circle\" class=\"size-4 text-red-600\"></i>\n    </span>\n    <p class=\"mt-1 text-[13px]/5 font-medium\">The register did not load</p>\n    <p class=\"text-[12px]/4 text-zinc-500\">Timed out after 30 seconds. Nothing was changed.</p>\n    <button type=\"button\" class=\"mt-1 inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[12px]/4 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"rotate-cw\" class=\"size-3.5 text-zinc-600\"></i>Retry\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django and htmx",
          "html": "<!-- views.py\n     def order_register(request):\n         # the page answers immediately with a spinner in the panel, and the\n         # panel fetches itself. Nothing on this path waits on the query.\n         return render(request, 'orders/register.html')\n\n     def order_register_rows(request):\n         try:\n             orders = (PurchaseOrder.objects\n                       .select_related('vendor')\n                       .filter(plant=request.user.plant)\n                       .order_by('-raised_on')[:50])\n         except DatabaseError:\n             # the failure renders the same fragment slot, so the swap always\n             # replaces the spinner with something. A view that 500s leaves the\n             # ring turning until somebody reloads.\n             return render(request, 'orders/_register_failed.html', status=200)\n         # the fragment only: no base template, no <html>\n         return render(request, 'orders/_register_rows.html', {'orders': orders})\n\n     urls.py\n         path('orders/', views.order_register, name='order-register'),\n         path('orders/rows/', views.order_register_rows, name='order-register-rows'),\n\n     A deferred load needs no indicator machinery at all. The spinner is what the\n     server rendered into the panel and hx-swap=\"outerHTML\" is what removes it, so\n     there is nothing to delay: the reason the panel is deferred is that the query\n     is slow.\n\n     Do not reach for htmx's .htmx-indicator class for the 500ms floor on the\n     refresh. htmx injects its own stylesheet, and .htmx-request .htmx-indicator\n     sets the transition shorthand at two classes of specificity, which resets\n     transition-delay to zero. Measured on an element carrying both delay-500 and\n     duration-150: computed transition-delay came back 0s and duration 0.2s —\n     htmx's values, not Tailwind's, and the floor is gone without a warning. Drive\n     the opacity off an Alpine flag instead, as below.\n\n     Bind htmx events in kebab case. htmx fires both htmx:beforeRequest and\n     htmx:before-request, but the HTML parser lowercases attribute names, so\n     @htmx:beforeRequest is stored as @htmx:beforerequest and listens for an event\n     nothing dispatches. Verified: with both spellings on one element, only the\n     kebab listener ran.\n\n     The listeners sit on the header, not on the panel root, so only requests that\n     started at the Refresh button bubble through them. On the root they would also\n     catch the deferred first load and put the refresh scrim over the spinner that\n     is already there.\n\n     role=\"status\" is on the panel, which survives the swap. A status region that\n     arrives inside a fragment with its message already in it never changed, so\n     there is nothing for a screen reader to report. -->\n\n{# orders/register.html #}\n<div id=\"register\" class=\"relative overflow-hidden rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{ busy: false }\" :aria-busy=\"busy\">\n\n  <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3\"\n       @htmx:before-request=\"busy = true\"\n       @htmx:after-request=\"busy = false\">\n    <h2 class=\"text-[14px]/5 font-semibold\">Order register</h2>\n    <button type=\"button\"\n            hx-get=\"{% url 'order-register-rows' %}\"\n            hx-target=\"#register-rows\" hx-swap=\"outerHTML\"\n            class=\"inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[12px]/4 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"rotate-cw\" class=\"size-3.5 text-zinc-600\"></i>Refresh\n    </button>\n  </div>\n\n  <!-- the deferred first load. This div is the spinner and it is also what\n       hx-swap=\"outerHTML\" replaces, so nothing has to remove it. -->\n  <div id=\"register-rows\"\n       hx-get=\"{% url 'order-register-rows' %}\"\n       hx-trigger=\"load\"\n       hx-swap=\"outerHTML\"\n       class=\"flex min-h-64 items-center justify-center p-6\">\n    <div class=\"flex flex-col items-center gap-3 text-center\">\n      <span class=\"size-8 shrink-0 animate-spin rounded-full border-[3px] border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      <p class=\"text-[13px]/5 text-zinc-600\">Loading the order register</p>\n    </div>\n  </div>\n\n  <!-- the refresh scrim, over rows that are already on screen -->\n  <div x-show=\"busy\" x-cloak class=\"absolute inset-0 z-10 flex items-center justify-center bg-white/70 px-4\">\n    <span class=\"flex items-center gap-2.5 rounded-full border border-zinc-200 bg-white px-3.5 py-2 shadow-sm\">\n      <span class=\"size-4 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-700\" aria-hidden=\"true\"></span>\n      <span class=\"text-[12px]/4 font-medium text-zinc-600\">Refreshing</span>\n    </span>\n  </div>\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"busy ? 'Refreshing the order register' : ''\"></p>\n</div>\n\n{# orders/_register_rows.html — the whole response and nothing around it,\n   keeping the id so the next Refresh still has a target #}\n<div id=\"register-rows\" :inert=\"busy\">\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n      </tr>\n    </thead>\n    <tbody>\n      {% for o in orders %}\n        <tr class=\"border-b border-zinc-100 last:border-0\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">{{ o.number }}</td>\n          <td class=\"px-4 py-2.5\">{{ o.vendor.name }}</td>\n          <td class=\"px-4 py-2.5 text-right tabular-nums\">₹{{ o.amount|intcomma }}</td>\n        </tr>\n      {% endfor %}\n    </tbody>\n  </table>\n</div>\n\n{# orders/_register_failed.html — the spinner resolved into an error, in the\n   same slot and at the same height, so the panel does not change size #}\n<div id=\"register-rows\" class=\"flex min-h-64 flex-col items-center justify-center gap-2 p-6 text-center\">\n  <span class=\"flex size-8 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"alert-circle\" class=\"size-4 text-red-600\"></i>\n  </span>\n  <p class=\"mt-1 text-[13px]/5 font-medium\">The register did not load</p>\n  <p class=\"text-[12px]/4 text-zinc-500\">Nothing was changed.</p>\n  <button type=\"button\" hx-get=\"{% url 'order-register-rows' %}\"\n          hx-target=\"#register-rows\" hx-swap=\"outerHTML\"\n          class=\"mt-1 inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[12px]/4 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"rotate-cw\" class=\"size-3.5 text-zinc-600\"></i>Retry\n  </button>\n</div>"
        }
      ]
    },
    {
      "id": "alert",
      "name": "Alert",
      "category": "feedback",
      "description": "An inline notice about the page or the record in front of the user. White card, zinc border, and the only colour is the icon.",
      "when_to_use": "A message that belongs to the page and should stay until it is read or dismissed. For a transient confirmation of something the user just did, use a toast.",
      "rules": [
        "Do not tint the alert body. bg-red-50 / bg-amber-50 panels shout against the graphite theme — the icon carries the severity on its own.",
        "An alert with no action is just noise. If there is nothing to do about it, put the text where it belongs instead.",
        "Errors coming back from a form belong on the field. A page-level alert may summarise them and link to each field, but it never replaces the field-level message.",
        "The page banner is graphite, never red. It is an emphasis surface, not a severity — a full-bleed red bar reads as an outage even when the message is routine.",
        "role=\"alert\" on markup already in the DOM at page load announces nothing. Put it on alerts that appear in response to an action, and leave it off static page notices.",
        "Never stack more than two alerts above a page. Past that nobody reads any of them — collapse the rest into one alert that links to a list.",
        "Dismiss is browser state only. The alert comes back on the next request unless the server was told about it."
      ],
      "anatomy": [
        {
          "part": "Icon",
          "description": "The only colour in the component, and the whole severity signal. size-4, shrink-0, mt-0.5 so it sits on the first line of text."
        },
        {
          "part": "Title",
          "description": "One sentence stating the fact, in sentence case. No \"Error:\" prefix — the icon already said that."
        },
        {
          "part": "Detail",
          "description": "Optional second line: the consequence, the deadline, or who to talk to. text-[12px]/4 text-zinc-600."
        },
        {
          "part": "Action",
          "description": "Optional, and at most one. The single thing to do about the message. shrink-0 so it never compresses."
        },
        {
          "part": "Dismiss",
          "description": "Optional, and only for alerts that are safe to lose. A 28px icon button on the right with aria-label=\"Dismiss\"."
        }
      ],
      "behaviour": [
        "Alerts stack in one region at the top of the content column, newest first, separated by 8px.",
        "The text block flexes and wraps; the icon and the action are shrink-0 and never compress.",
        "The action wraps below the text on narrow screens — the row is flex-wrap and the text block carries a min-width for that reason.",
        "Dismissing hides the alert in the browser. Nothing is sent to the server, so a dismissal that must survive a reload has to be recorded there.",
        "A page banner sits outside the content column, above the application header, and spans the full viewport.",
        "A form error summary takes focus when it renders so the keyboard is already at the problem."
      ],
      "accessibility": [
        "Error and warning alerts that appear in response to an action carry role=\"alert\" so they are announced.",
        "The icon is decorative and carries no label — the sentence alone says what happened and how bad it is.",
        "Colour is never the only signal. The wording states the severity as well, for the 8% of men who will not see the difference between the amber and the red icon.",
        "The dismiss button has aria-label=\"Dismiss\" because its only content is an icon.",
        "The form error summary is focusable with tabindex=\"-1\", and every entry links to its field id so the keyboard lands on the input, not near it."
      ],
      "related": [
        "toast",
        "alert-dialog",
        "badge"
      ],
      "variants": [
        {
          "id": "tags",
          "name": "Four severities",
          "html": "<div class=\"flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"info\" class=\"mt-0.5 size-4 shrink-0 text-zinc-500\"></i>\n  <div class=\"min-w-0\">\n    <p class=\"text-[13px]/5 font-medium\">Rate contract with Sharma Extrusions expires on 30 Sep 2024.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Orders raised after that date will price at the spot rate.</p>\n  </div>\n</div>\n\n<div class=\"mt-2 flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"check-circle-2\" class=\"mt-0.5 size-4 shrink-0 text-emerald-600\"></i>\n  <div class=\"min-w-0\">\n    <p class=\"text-[13px]/5 font-medium\">PO-24-1187 emailed to Sharma Extrusions.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Sent by Ritu Deshpande, 19 Aug 2024 at 11:42.</p>\n  </div>\n</div>\n\n<div class=\"mt-2 flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"alert-triangle\" class=\"mt-0.5 size-4 shrink-0 text-amber-700\"></i>\n  <div class=\"min-w-0\">\n    <p class=\"text-[13px]/5 font-medium\">GRN pending for 3 orders older than 30 days.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Nashik Steel Traders, Gujarat Polymers Ltd, Sharma Extrusions.</p>\n  </div>\n</div>\n\n<div class=\"mt-2 flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\" role=\"alert\">\n  <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n  <div class=\"min-w-0\">\n    <p class=\"text-[13px]/5 font-medium\">Order value <span class=\"tabular-nums\">₹18,42,000</span> exceeds your approval limit.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Anything above ₹10,00,000 goes to the plant head.</p>\n  </div>\n</div>"
        },
        {
          "id": "compact",
          "name": "Compact",
          "html": "<!-- One line, 32px tall. For a table toolbar or a card header, where a\n     three-line alert would push the data below the fold. -->\n<div class=\"flex items-center gap-2.5 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n  <i data-lucide=\"alert-triangle\" class=\"size-3.5 shrink-0 text-amber-700\"></i>\n  <p class=\"min-w-0 flex-1 truncate text-[12px]/4\">Showing the first 200 of <span class=\"tabular-nums\">1,438</span> matching rows.</p>\n  <a href=\"#\" class=\"shrink-0 text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Narrow the filters</a>\n</div>"
        },
        {
          "id": "dismissible",
          "name": "Dismissible",
          "html": "<div x-data=\"{ show: true }\" x-show=\"show\" x-cloak\n     class=\"flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"info\" class=\"mt-0.5 size-4 shrink-0 text-zinc-500\"></i>\n  <div class=\"min-w-0 flex-1\">\n    <p class=\"text-[13px]/5 font-medium\">Vendor bank details changed on 12 Aug 2024.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Payments to Nashik Steel Traders now go to HDFC ••4471.</p>\n  </div>\n  <button type=\"button\" @click=\"show = false\" aria-label=\"Dismiss\"\n          class=\"-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n    <i data-lucide=\"x\" class=\"size-4\"></i>\n  </button>\n</div>"
        },
        {
          "id": "action",
          "name": "With action",
          "html": "<div class=\"flex flex-wrap items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"alert-triangle\" class=\"mt-0.5 size-4 shrink-0 text-amber-700\"></i>\n  <div class=\"min-w-[16rem] flex-1\">\n    <p class=\"text-[13px]/5 font-medium\">PO-24-1163 has been waiting for approval for 9 days.</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">\n      Nashik Steel Traders · <span class=\"tabular-nums\">₹4,26,500</span> ·\n      <a href=\"#\" class=\"text-zinc-900 underline underline-offset-2\">open the order</a>\n    </p>\n  </div>\n  <button type=\"button\" class=\"rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">Remind approver</button>\n</div>"
        },
        {
          "id": "records",
          "name": "With affected records",
          "html": "<!-- When the message is about a set of rows, name them. \"4 invoices failed\"\n     with no list means someone has to go hunting for which four. -->\n<div class=\"rounded-lg border border-zinc-200 bg-white px-4 py-3\" role=\"alert\">\n  <div class=\"flex items-start gap-3\">\n    <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n    <div class=\"min-w-0\">\n      <p class=\"text-[13px]/5 font-medium\">3 invoices could not be matched to a GRN.</p>\n      <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">They stay out of the payment run until a GRN is posted against each one.</p>\n    </div>\n  </div>\n  <ul class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3 text-[12px]/4\">\n    <li class=\"flex items-center justify-between gap-4\">\n      <a href=\"#\" class=\"truncate text-zinc-900 underline underline-offset-2\">INV-8841 · Nashik Steel Traders</a>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">₹4,26,500</span>\n    </li>\n    <li class=\"flex items-center justify-between gap-4\">\n      <a href=\"#\" class=\"truncate text-zinc-900 underline underline-offset-2\">INV-8836 · Gujarat Polymers Ltd</a>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">₹1,18,240</span>\n    </li>\n    <li class=\"flex items-center justify-between gap-4\">\n      <a href=\"#\" class=\"truncate text-zinc-900 underline underline-offset-2\">INV-8829 · Sharma Extrusions</a>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">₹87,900</span>\n    </li>\n  </ul>\n</div>"
        },
        {
          "id": "progress",
          "name": "In progress",
          "html": "<!-- A job the user started that outlives the request. Poll the bar with htmx:\n     hx-get=\"/imports/8841/progress/\" hx-trigger=\"every 2s\" hx-swap=\"outerHTML\". -->\n<div class=\"rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <div class=\"flex items-start gap-3\">\n    <i data-lucide=\"loader-2\" class=\"mt-0.5 size-4 shrink-0 animate-spin text-zinc-500\"></i>\n    <div class=\"min-w-0 flex-1\">\n      <p class=\"text-[13px]/5 font-medium\">Importing the August rate card.</p>\n      <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">1,240 of 3,100 rows · you can leave this page, it keeps running.</p>\n    </div>\n    <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-600\">40%</span>\n  </div>\n  <div class=\"mt-3 h-1 overflow-hidden rounded-full bg-zinc-100\">\n    <div class=\"h-full rounded-full bg-zinc-700 transition-[width] duration-500\" style=\"width: 40%\"></div>\n  </div>\n</div>"
        },
        {
          "id": "banner",
          "name": "Page banner",
          "html": "<!-- Full-bleed, above the application header, outside the content column.\n     Graphite — never red. A red bar across the top reads as an outage. -->\n<div x-data=\"{ show: true }\" x-show=\"show\" x-cloak\n     class=\"flex flex-wrap items-center gap-x-3 gap-y-1 bg-zinc-900 px-4 py-2.5 text-white sm:px-6\">\n  <i data-lucide=\"wrench\" class=\"size-4 shrink-0 text-zinc-500\"></i>\n  <p class=\"min-w-[14rem] flex-1 text-[13px]/5\">\n    <span class=\"font-medium\">Scheduled maintenance.</span>\n    <span class=\"text-zinc-500\">Purchase orders are read-only on Sun 24 Aug, 01:00–03:00 IST.</span>\n  </p>\n  <a href=\"#\" class=\"shrink-0 text-[13px]/5 font-medium underline underline-offset-2\">What changes</a>\n  <button type=\"button\" @click=\"show = false\" aria-label=\"Dismiss\"\n          class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-800 hover:text-white\">\n    <i data-lucide=\"x\" class=\"size-4\"></i>\n  </button>\n</div>"
        },
        {
          "id": "form-errors",
          "name": "Form error summary",
          "html": "<!-- {{ form.non_field_errors }} plus one line per field that has errors.\n     It summarises; it does not replace the message under each input.\n     x-init focuses it so the keyboard is already at the problem. -->\n<div role=\"alert\" tabindex=\"-1\" x-data x-init=\"$el.focus()\"\n     class=\"rounded-lg border border-zinc-200 bg-white px-4 py-3 outline-none\">\n  <div class=\"flex items-start gap-3\">\n    <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n    <div class=\"min-w-0\">\n      <p class=\"text-[13px]/5 font-medium\">This order was not saved — 3 fields need attention.</p>\n      <ul class=\"mt-2 space-y-1 text-[12px]/4 text-zinc-600\">\n        <li>\n          <a href=\"#id_vendor\" class=\"font-medium text-zinc-900 underline underline-offset-2\">Vendor</a>\n          — select a vendor.\n        </li>\n        <li>\n          <a href=\"#id_delivery_date\" class=\"font-medium text-zinc-900 underline underline-offset-2\">Delivery date</a>\n          — cannot be before the order date.\n        </li>\n        <li>\n          <a href=\"#id_quantity\" class=\"font-medium text-zinc-900 underline underline-offset-2\">Quantity</a>\n          — enter a whole number greater than zero.\n        </li>\n      </ul>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django messages block",
          "html": "<!-- {% if messages %}{% for message in messages %} … {% endfor %}{% endif %}\n     Swap the icon and its colour on message.tags: success / warning / error,\n     anything else falls through to the neutral info icon. Give error and\n     warning role=\"alert\"; success and info do not need it. -->\n<div x-data=\"{ show: true }\" x-show=\"show\" x-cloak\n     class=\"flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3\">\n  <i data-lucide=\"check-circle-2\" class=\"mt-0.5 size-4 shrink-0 text-emerald-600\"></i>\n  <p class=\"min-w-0 flex-1 text-[13px]/5 font-medium\">GRN 1142 posted against PO-24-1187.</p>\n  <button type=\"button\" @click=\"show = false\" aria-label=\"Dismiss\"\n          class=\"-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n    <i data-lucide=\"x\" class=\"size-4\"></i>\n  </button>\n</div>"
        }
      ]
    },
    {
      "id": "toast",
      "name": "Toast",
      "category": "feedback",
      "description": "A short confirmation that something finished, pinned to the bottom-left and gone a few seconds later.",
      "when_to_use": "Saves, posts, exports — anything the user triggered and does not need to act on. Never put an error the user must read in a toast; it will disappear before they get to it.",
      "rules": [
        "Auto-hide only works when the toast is dismissable and repeatable. If losing the message costs the user something, use an alert.",
        "Bottom-left keeps it clear of the right-hand drawer and the row action menus.",
        "A toast with an Undo action must not auto-hide faster than the undo window on the server."
      ],
      "anatomy": [
        {
          "part": "Region",
          "description": "A fixed container at the bottom-left, clear of the right-hand drawer and the row action menus."
        },
        {
          "part": "Icon",
          "description": "The severity, at size-4. The same palette as an alert, for the same reason."
        },
        {
          "part": "Message",
          "description": "One line, past tense, naming what happened. \"GRN 1142 posted\", not \"Success\"."
        },
        {
          "part": "Action",
          "description": "Optional, and almost always Undo. Anything else usually belongs in an alert instead."
        },
        {
          "part": "Dismiss",
          "description": "Always present, so a toast can be got rid of before its timer runs out."
        }
      ],
      "behaviour": [
        "It appears after the action it reports, never before, and auto-hides a few seconds later.",
        "An error the user must read never goes in a toast — it will disappear before they reach it. That is an alert.",
        "A toast carrying Undo must not auto-hide sooner than the server's undo window, or the offer expires while it is still on screen.",
        "Toasts stack upward, newest at the bottom, and older ones are pushed rather than replaced.",
        "Hovering a toast pauses its timer, because a user reading it is not a user ignoring it."
      ],
      "accessibility": [
        "The region is aria-live=\"polite\" so a new toast is announced without interrupting.",
        "An error toast — if one is unavoidable — is role=\"alert\", which interrupts, and should be an alert instead.",
        "The dismiss button has aria-label=\"Dismiss\", since its only content is an icon.",
        "Auto-hide is paused on hover and on focus, so a keyboard user has time to reach the Undo.",
        "Nothing in a toast is the only path to an action. If it matters, it exists somewhere permanent too."
      ],
      "related": [
        "alert",
        "alert-dialog",
        "badge"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div x-data=\"{ show: true }\" x-init=\"setTimeout(() => show = false, 2600)\"\n     x-show=\"show\" x-cloak\n     x-transition:enter=\"transition ease-out duration-200\"\n     x-transition:enter-start=\"opacity-0 translate-y-2\"\n     x-transition:leave=\"transition ease-in duration-150\"\n     x-transition:leave-end=\"opacity-0 translate-y-2\"\n     class=\"fixed bottom-4 left-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm items-start gap-3 rounded-xl border border-zinc-200 bg-white px-3.5 py-3 shadow-lg\">\n  <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\">\n    <i data-lucide=\"check\" class=\"size-4 text-emerald-600\"></i>\n  </span>\n  <div class=\"min-w-0 flex-1\">\n    <p class=\"truncate text-[13px]/5 font-medium\">PO-24-1187 saved</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-500\">Just now</p>\n  </div>\n  <button type=\"button\" @click=\"show = false\" aria-label=\"Dismiss\"\n          class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n    <i data-lucide=\"x\" class=\"size-4\"></i>\n  </button>\n</div>"
        },
        {
          "id": "undo",
          "name": "With undo action",
          "html": "<div x-data=\"{ show: true }\" x-init=\"setTimeout(() => show = false, 6000)\"\n     x-show=\"show\" x-cloak\n     x-transition:enter=\"transition ease-out duration-200\"\n     x-transition:enter-start=\"opacity-0 translate-y-2\"\n     x-transition:leave=\"transition ease-in duration-150\"\n     x-transition:leave-end=\"opacity-0 translate-y-2\"\n     class=\"fixed bottom-4 left-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm items-start gap-3 rounded-xl border border-zinc-200 bg-white px-3.5 py-3 shadow-lg\">\n  <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\">\n    <i data-lucide=\"trash-2\" class=\"size-4 text-zinc-600\"></i>\n  </span>\n  <div class=\"min-w-0 flex-1\">\n    <p class=\"truncate text-[13px]/5 font-medium\">Line 4 removed from PO-24-1187</p>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-500\">MS plate 10 mm · <span class=\"tabular-nums\">₹1,08,400</span></p>\n  </div>\n  <button type=\"button\" @click=\"show = false\"\n          class=\"shrink-0 text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">Undo</button>\n</div>"
        }
      ]
    },
    {
      "id": "alert-dialog",
      "name": "Alert dialog",
      "category": "feedback",
      "description": "A dialog that interrupts to ask one question with two answers. Unlike a modal it does not dismiss on a backdrop click — the user has to choose.",
      "when_to_use": "Anything destructive, irreversible, or expensive to undo: deleting a record, closing an order, discarding unsaved work, acting on a selection. If the answer does not matter much, do not interrupt at all.",
      "rules": [
        "No @click.self on the backdrop. A modal dismisses on a stray backdrop click; an alert dialog must not — the user is being asked a question and a misplaced click is not an answer.",
        "Escape still closes, and it means cancel. Taking that away makes the dialog a trap, which is a worse failure than an accidental dismissal.",
        "role=\"alertdialog\", not role=\"dialog\". It tells a screen reader to announce the body immediately instead of waiting for the user to navigate to it — which only works if aria-describedby points at that body.",
        "x-trap.noscroll does three jobs: it traps Tab inside the dialog, it returns focus to the trigger on close, and it locks the page behind. Without it Tab walks straight out into the page underneath, which is still fully interactive.",
        "The focus trap lands on the first focusable element, so order the DOM to make that the safe one — Cancel, or the input in a typed confirmation. Focus must never open on the button that deletes something, even though it is the last one visually.",
        "Name the record in the heading. \"Are you sure?\" tells the user nothing about what they are about to lose.",
        "Say what else goes with it. If deleting the order also deletes a GRN and two approvals, that belongs in the dialog, not in a toast afterwards.",
        "The confirm button repeats the verb — \"Delete order\", never \"OK\". Someone who reads only the buttons still knows what is about to happen."
      ],
      "anatomy": [
        {
          "part": "Backdrop",
          "description": "Dims the page and centres the panel. It carries no click handler at all — that is the whole difference from a modal."
        },
        {
          "part": "Container",
          "description": "role=\"alertdialog\" and aria-modal=\"true\", labelled by the heading and described by the consequence line."
        },
        {
          "part": "Heading",
          "description": "The verb and the record, in one line. \"Delete PO-24-1187\", not \"Confirm deletion\"."
        },
        {
          "part": "Consequence",
          "description": "What else changes, and whether it can be undone. The only place colour appears in the dialog."
        },
        {
          "part": "Actions",
          "description": "Two, or three when there is a middle answer. Cancel is first in the DOM so it takes focus; the confirm button carries the verb."
        }
      ],
      "behaviour": [
        "Opening moves focus into the dialog and onto the safe action, not the confirm.",
        "Tab and Shift+Tab cycle inside the dialog only. Nothing behind it is reachable.",
        "Escape cancels. A backdrop click does nothing.",
        "Closing returns focus to the control that opened it, so the keyboard does not lose its place.",
        "The page behind does not scroll while the dialog is open.",
        "Where a precondition exists, the confirm button stays genuinely disabled until it is met — not enabled-but-ignored."
      ],
      "accessibility": [
        "role=\"alertdialog\" with aria-modal=\"true\", so assistive tech treats the rest of the page as inert.",
        "aria-labelledby points at the heading and aria-describedby at the consequence text. Without the second one the reason is never announced.",
        "Focus enters on the safe action and cannot leave the dialog while it is open.",
        "Focus returns to the trigger when the dialog closes.",
        "Escape closes the dialog from anywhere inside it.",
        "A blocked confirm is a real disabled button, so the keyboard skips it and a screen reader says it is unavailable, rather than it silently doing nothing."
      ],
      "related": [
        "modal",
        "alert",
        "drawer"
      ],
      "variants": [
        {
          "id": "confirm",
          "name": "Confirm",
          "html": "<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Close order</button>\n\n  <!-- no @click.self here, and that is deliberate: the backdrop does not answer for the user -->\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"close-title\" aria-describedby=\"close-body\"\n         class=\"w-full max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-lg\">\n      <h2 id=\"close-title\" class=\"text-[16px]/6 font-semibold\">Close PO-24-1187?</h2>\n      <p id=\"close-body\" class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n        Sharma Extrusions · <span class=\"tabular-nums\">₹18,42,000</span>. All 6 lines are fully received.\n        Closing stops any further GRN against this order. You can reopen it from the order page.\n      </p>\n      <div class=\"mt-5 flex flex-wrap justify-end gap-2\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Close order</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "destructive",
          "name": "Destructive",
          "html": "<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium text-red-600 hover:bg-zinc-100\">Delete order</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"del-title\" aria-describedby=\"del-body\"\n         class=\"w-full max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-lg\">\n      <div class=\"flex items-start gap-3\">\n        <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n        <div class=\"min-w-0\">\n          <h2 id=\"del-title\" class=\"text-[16px]/6 font-semibold\">Delete PO-24-1187</h2>\n          <p class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n            Nashik Steel Traders · <span class=\"tabular-nums\">₹18,42,000</span> · raised 04 Aug 2024.\n          </p>\n          <p id=\"del-body\" class=\"mt-2 text-[13px]/5 font-medium text-red-600\">\n            GRN 1142 and 2 approvals are linked to this order and will be deleted with it. This cannot be undone.\n          </p>\n        </div>\n      </div>\n      <div class=\"mt-5 flex flex-wrap justify-end gap-2\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-red-600 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-red-700\">Delete order</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "typed",
          "name": "Typed confirmation",
          "html": "<!-- For the small number of actions that cannot be undone at all. Do not reach\n     for this every time something is deleted — if every dialog asks for typing,\n     people learn to type without reading. -->\n<div x-data=\"{ open: false, typed: '', target: 'SHARMA-EXT' }\">\n  <button type=\"button\" @click=\"open = true; typed = ''\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium text-red-600 hover:bg-zinc-100\">Delete vendor</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"wipe-title\" aria-describedby=\"wipe-body\"\n         class=\"w-full max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-lg\">\n      <h2 id=\"wipe-title\" class=\"text-[16px]/6 font-semibold\">Delete Sharma Extrusions</h2>\n      <p id=\"wipe-body\" class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n        <span class=\"font-medium text-red-600\">34 orders, 61 invoices and the rate contract</span>\n        are attached to this vendor and go with it. There is no recycle bin and no undo.\n      </p>\n\n      <div class=\"mt-4\">\n        <label for=\"wipe-code\" class=\"mb-1.5 block text-[13px]/5 font-medium\">\n          Type <code class=\"rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[12px]/4\" x-text=\"target\"></code> to confirm\n        </label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <input id=\"wipe-code\" x-model=\"typed\" :placeholder=\"target\" autocomplete=\"off\" spellcheck=\"false\"\n                 class=\"w-full bg-transparent px-3 py-2 font-mono text-[14px]/5 outline-none placeholder:text-zinc-500\">\n        </div>\n      </div>\n\n      <div class=\"mt-5 flex flex-wrap justify-end gap-2\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n        <button type=\"button\" @click=\"open = false\" :disabled=\"typed !== target\"\n                class=\"rounded-lg px-4 py-2 text-[13px]/5 font-medium\"\n                :class=\"typed === target ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-zinc-200 text-zinc-500'\">\n          Delete vendor\n        </button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "bulk",
          "name": "Bulk action",
          "html": "<!-- Acting on a selection. Say how many, and say which ones will not go\n     through — a bulk action that silently skips rows is the worst kind. -->\n<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve 12 selected</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"bulk-title\" aria-describedby=\"bulk-body\"\n         class=\"w-full max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-lg\">\n      <h2 id=\"bulk-title\" class=\"text-[16px]/6 font-semibold\">Approve 12 purchase orders</h2>\n      <p id=\"bulk-body\" class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n        Total value <span class=\"font-medium tabular-nums text-zinc-900\">₹94,18,600</span>.\n        Each vendor is emailed a copy as soon as the order is approved.\n      </p>\n\n      <dl class=\"mt-4 space-y-2 rounded-lg border border-zinc-200 px-4 py-3 text-[13px]/5\">\n        <div class=\"flex items-center justify-between gap-4\">\n          <dt class=\"flex items-center gap-2 text-zinc-600\">\n            <i data-lucide=\"check-circle-2\" class=\"size-4 shrink-0 text-emerald-600\"></i>Ready to approve\n          </dt>\n          <dd class=\"shrink-0 font-medium tabular-nums\">9</dd>\n        </div>\n        <div class=\"flex items-center justify-between gap-4\">\n          <dt class=\"flex items-center gap-2 text-zinc-600\">\n            <i data-lucide=\"alert-triangle\" class=\"size-4 shrink-0 text-amber-700\"></i>Above your limit, will route on\n          </dt>\n          <dd class=\"shrink-0 font-medium tabular-nums\">3</dd>\n        </div>\n      </dl>\n\n      <div class=\"mt-5 flex flex-wrap justify-end gap-2\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve 12 orders</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "unsaved",
          "name": "Three answers",
          "html": "<!-- Discard / Cancel / Save. When there is a middle answer, offer it — forcing\n     a two-way choice makes people pick the destructive one to get out. -->\n<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Leave page</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"leave-title\" aria-describedby=\"leave-body\"\n         class=\"w-full max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-lg\">\n      <h2 id=\"leave-title\" class=\"text-[16px]/6 font-semibold\">Leave without saving?</h2>\n      <p id=\"leave-body\" class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n        PO-24-1187 has 4 unsaved line items and a changed delivery date. Leaving now loses them.\n      </p>\n      <div class=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Keep editing</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg px-4 py-2 text-[13px]/5 font-medium text-red-600 hover:bg-zinc-100\">Discard changes</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Save and leave</button>\n      </div>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "modal",
      "name": "Modal",
      "category": "feedback",
      "description": "A centred, dismissible dialog over a dimmed page. Use it to take a handful of fields without leaving the list.",
      "when_to_use": "Short forms and anything the user can walk away from. A form longer than about six fields belongs on its own page; a record to read belongs in a drawer; a question that must be answered belongs in an alert dialog.",
      "rules": [
        "Close on escape and on a backdrop click with @click.self. A modal with no way out except the button is a trap.",
        "x-cloak on the overlay, otherwise it flashes over the page on load.",
        "x-trap.noscroll on the backdrop, exactly as the alert dialog uses it: Tab stays inside the panel, focus returns to the trigger on close, and the page behind stops scrolling. @click.self sits on the same element and still fires — the focus plugin runs with allowOutsideClick, and a click on the backdrop is inside the trap anyway.",
        "x-trap opens focus on the first element inside the panel carrying autofocus, and on the first focusable element when there is none. Put autofocus on the first field when the panel exists to be typed into; leave it off when the panel is only read, and focus lands on the close button, which is the safe one.",
        "A modal is dismissible by definition. The moment a stray backdrop click would lose the user something, it is an alert dialog and it belongs in that component instead."
      ],
      "anatomy": [
        {
          "part": "Backdrop",
          "description": "A zinc-900/40 field that dims the page and carries @click.self to dismiss, plus x-trap.noscroll to hold focus and lock the page behind."
        },
        {
          "part": "Panel",
          "description": "Centred, rounded-xl, max-w-md for a form and max-w-lg when the body scrolls. Capped at 80vh in every variant."
        },
        {
          "part": "Header",
          "description": "The title and a close button, on a bordered strip that does not scroll."
        },
        {
          "part": "Body",
          "description": "The fields, or the content. This is the only part that scrolls."
        },
        {
          "part": "Footer",
          "description": "Cancel and the primary action, right-aligned on a zinc-100 strip that does not scroll."
        }
      ],
      "behaviour": [
        "Escape closes, a backdrop click closes, and the close button closes. All three, always.",
        "Opening moves focus into the panel: onto the first field where there is one to fill in, otherwise onto the close button.",
        "Tab and Shift+Tab cycle inside the panel only. Nothing behind it is reachable while it is open.",
        "Closing returns focus to the control that opened it, so the keyboard does not lose its place.",
        "In the scrolling variant only the body moves; the header and footer stay, so the primary action never scrolls out of reach.",
        "Every panel is capped at 80vh with the body scrolling inside it, so a modal never grows past the viewport on a laptop or in landscape on a phone.",
        "The page behind does not scroll while the modal is open, and it does not jump sideways when its scrollbar goes — x-trap.noscroll pads for the width it removes.",
        "A form longer than about six fields belongs on a page. A modal that scrolls a long form is a page in a costume."
      ],
      "accessibility": [
        "role=\"dialog\" with aria-modal=\"true\" and aria-labelledby pointing at the heading.",
        "Focus moves into the panel on open and returns to the trigger on close — x-trap from @alpinejs/focus does both, the same as the alert dialog.",
        "Focus is trapped inside while it is open, so Tab cannot walk out into the page underneath, which is still fully rendered.",
        "A form modal opens on its first field, marked with autofocus, which x-trap honours. A read-only one opens on the close button.",
        "The close button carries aria-label=\"Close\".",
        "Escape works from anywhere inside the panel, including from within a focused input."
      ],
      "related": [
        "alert-dialog",
        "drawer",
        "form-page"
      ],
      "variants": [
        {
          "id": "form",
          "name": "Form modal",
          "html": "<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n    <i data-lucide=\"plus\" class=\"size-4\"></i>Record GRN\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\" @click.self=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"grn-title\"\n         class=\"flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n      <div class=\"flex shrink-0 items-start justify-between gap-3 border-b border-zinc-200 px-5 py-4\">\n        <div class=\"min-w-0\">\n          <h2 id=\"grn-title\" class=\"text-[16px]/6 font-semibold\">Record GRN</h2>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">PO-24-1187 · Sharma Extrusions</p>\n        </div>\n        <button type=\"button\" @click=\"open = false\" aria-label=\"Close\"\n                class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-4\"></i>\n        </button>\n      </div>\n\n      <div class=\"min-h-0 flex-1 overflow-y-auto px-5 py-4\">\n        <div>\n          <label for=\"grn-qty\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Quantity received <span class=\"text-red-600\">*</span></label>\n          <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <!-- x-trap opens focus on [autofocus] if the panel has one, otherwise on the close button -->\n            <input id=\"grn-qty\" autofocus value=\"4,200\" class=\"w-full bg-transparent px-3 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n            <span class=\"pr-3 text-[13px]/5 text-zinc-600\">kg</span>\n          </div>\n          <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Ordered 12,000 kg, received 7,800 kg so far.</p>\n        </div>\n\n        <div class=\"mt-4\">\n          <label for=\"grn-date\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Receipt date <span class=\"text-red-600\">*</span></label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <input id=\"grn-date\" type=\"date\" value=\"2024-08-19\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n          </div>\n        </div>\n      </div>\n\n      <div class=\"flex shrink-0 flex-wrap justify-end gap-2 border-t border-zinc-200 bg-zinc-100 px-5 py-3\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Save</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "scrolling",
          "name": "Scrolling body",
          "html": "<!-- Header and footer stay put, only the middle scrolls. max-h on the panel,\n     overflow-y on the body, and min-h-0 so the flex child is allowed to shrink. -->\n<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Review 6 lines</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\" @click.self=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"lines-title\"\n         class=\"flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n      <div class=\"flex shrink-0 items-start justify-between gap-3 border-b border-zinc-200 px-5 py-4\">\n        <div class=\"min-w-0\">\n          <h2 id=\"lines-title\" class=\"text-[16px]/6 font-semibold\">Order lines</h2>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">PO-24-1187 · Sharma Extrusions · 6 lines</p>\n        </div>\n        <button type=\"button\" @click=\"open = false\" aria-label=\"Close\"\n                class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-4\"></i>\n        </button>\n      </div>\n\n      <div class=\"min-h-0 flex-1 overflow-y-auto px-5 py-2\">\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS angle 50×50×6</p><p class=\"text-[12px]/4 text-zinc-600\">12,000 kg · received 7,800</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹6,84,000</span>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS plate 10 mm</p><p class=\"text-[12px]/4 text-zinc-600\">4,000 kg · received 4,000</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹3,12,000</span>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS channel 100×50</p><p class=\"text-[12px]/4 text-zinc-600\">6,500 kg · received 6,500</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹4,41,000</span>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS flat 40×6</p><p class=\"text-[12px]/4 text-zinc-600\">2,200 kg · received 2,200</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹1,54,000</span>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS round bar 20 mm</p><p class=\"text-[12px]/4 text-zinc-600\">3,100 kg · received 3,100</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹2,17,000</span>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 py-3\">\n          <div class=\"min-w-0\"><p class=\"truncate text-[13px]/5 font-medium\">MS square tube 40×40</p><p class=\"text-[12px]/4 text-zinc-600\">900 kg · received 900</p></div>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹34,000</span>\n        </div>\n      </div>\n\n      <div class=\"flex shrink-0 items-center justify-between gap-3 border-t border-zinc-200 bg-zinc-100 px-5 py-3\">\n        <span class=\"text-[13px]/5 text-zinc-600\">Total <span class=\"font-medium tabular-nums text-zinc-900\">₹18,42,000</span></span>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Done</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "reference",
          "name": "Reference sheet",
          "html": "<!-- Read-only, nothing to submit, so the only control is a close button. -->\n<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"keyboard\" class=\"size-4\"></i>Shortcuts\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\" @click.self=\"open = false\"\n       class=\"fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"keys-title\"\n         class=\"flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n      <div class=\"flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-5 py-4\">\n        <h2 id=\"keys-title\" class=\"text-[16px]/6 font-semibold\">Keyboard shortcuts</h2>\n        <button type=\"button\" @click=\"open = false\" aria-label=\"Close\"\n                class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-4\"></i>\n        </button>\n      </div>\n      <dl class=\"min-h-0 flex-1 overflow-y-auto px-5 py-2\">\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-2.5\">\n          <dt class=\"text-[13px]/5 text-zinc-600\">Search orders</dt>\n          <dd><kbd class=\"rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[11px]/4 text-zinc-600\">/</kbd></dd>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-2.5\">\n          <dt class=\"text-[13px]/5 text-zinc-600\">New purchase order</dt>\n          <dd><kbd class=\"rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[11px]/4 text-zinc-600\">N</kbd></dd>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 border-b border-zinc-100 py-2.5\">\n          <dt class=\"text-[13px]/5 text-zinc-600\">Select all rows</dt>\n          <dd><kbd class=\"rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[11px]/4 text-zinc-600\">Ctrl A</kbd></dd>\n        </div>\n        <div class=\"flex items-center justify-between gap-4 py-2.5\">\n          <dt class=\"text-[13px]/5 text-zinc-600\">Close this dialog</dt>\n          <dd><kbd class=\"rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[11px]/4 text-zinc-600\">Esc</kbd></dd>\n        </div>\n      </dl>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "drawer",
      "name": "Drawer",
      "category": "feedback",
      "description": "A panel that slides in from the right over the page. Shows a record, or the filters for a list, without losing the user's place in it.",
      "when_to_use": "Reading or filtering while the list stays behind. If the user has to edit a lot, send them to a page instead — a drawer is too narrow for a long form.",
      "rules": [
        "Full width below sm, fixed width above it. A 448px drawer on a 390px phone is a horizontal scrollbar.",
        "The body scrolls, the header and footer do not. overflow-y-auto goes on the middle section only.",
        "Escape and backdrop both close it, same as a modal.",
        "x-trap.noscroll on the backdrop, the same as the modal and the alert dialog: Tab stays inside the panel, focus returns to the row that opened it on close, and the list behind stops scrolling.",
        "motion-reduce:transition-none and motion-reduce:duration-0 ride along on both x-transition class lists. Alpine puts the panel in its final position by removing translate-x-full itself rather than waiting on a transitionend, so killing the transition still lands the drawer open — it just gets there in one frame. duration-0 is the second half of it: Alpine reads the computed transition-duration to decide how long to hold the element before hiding it, so without it the backdrop would sit on screen for another 150ms after the panel had already gone.",
        "None of the transition classes are in the stylesheet when the page first paints, because they live in x-transition attributes and the browser build scans class attributes only. That is not a defect and it does not want a safelist. Tailwind compiles a class when Alpine writes it onto the element, and Alpine holds translate-x-full on the panel for a frame before taking it off again, which is long enough for the rule to exist before the start style is read. Measured on this page: ten eased frames between 100% and 0 with motion allowed, and two values with nothing in between under prefers-reduced-motion. Check it by reading the translate property and not transform, because Tailwind v4 compiles translate-x-full to the independent translate property and transform stays none the whole way, which reads as a dead animation when it is nothing of the sort."
      ],
      "anatomy": [
        {
          "part": "Backdrop",
          "description": "The same dimmed field as a modal, dismissing on @click.self and carrying x-trap.noscroll to hold focus and lock the list behind."
        },
        {
          "part": "Panel",
          "description": "Anchored right, full height, full width below sm and a fixed width above it."
        },
        {
          "part": "Header",
          "description": "The record's name and a close button. Fixed."
        },
        {
          "part": "Body",
          "description": "The only scrolling section — overflow-y-auto goes here and nowhere else."
        },
        {
          "part": "Footer",
          "description": "The actions for the record. Fixed, so they are reachable from anywhere in a long body."
        }
      ],
      "behaviour": [
        "It slides in from the right and the list stays visible behind it, which is the whole point of choosing a drawer.",
        "Full width below sm. A 448px panel on a 390px phone is a horizontal scrollbar.",
        "Escape and a backdrop click both close it, the same as a modal.",
        "The body scrolls independently; the header and footer do not move.",
        "Tab stays inside the panel while it is open. The list behind is visible but not reachable, and it does not scroll.",
        "Under prefers-reduced-motion the panel appears in place instead of sliding across.",
        "For heavy editing, send the user to a page. A drawer is too narrow for a long form and they will fight the width."
      ],
      "accessibility": [
        "role=\"dialog\" with aria-modal=\"true\", labelled by the record name in the header.",
        "Focus enters the panel on open and returns to the row that opened it on close — x-trap does both.",
        "Focus is trapped while open, or Tab walks into the list behind and the user is lost.",
        "Focus lands on the close button, the first focusable element in the panel and the one that costs nothing to press.",
        "The close button carries aria-label=\"Close\".",
        "The slide transition respects prefers-reduced-motion, through motion-reduce:transition-none and motion-reduce:duration-0 on both x-transition class lists. The panel still ends up open and in place; it just does not travel."
      ],
      "related": [
        "modal",
        "list-detail",
        "table"
      ],
      "variants": [
        {
          "id": "record",
          "name": "Record detail",
          "html": "<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Open PO-24-1187</button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\" @click.self=\"open = false\"\n       class=\"fixed inset-0 z-50 flex justify-end bg-zinc-900/40\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"drawer-title\"\n         x-show=\"open\"\n         x-transition:enter=\"transition ease-out duration-200 motion-reduce:transition-none motion-reduce:duration-0\"\n         x-transition:enter-start=\"translate-x-full\"\n         x-transition:leave=\"transition ease-in duration-150 motion-reduce:transition-none motion-reduce:duration-0\"\n         x-transition:leave-end=\"translate-x-full\"\n         class=\"flex h-full w-full flex-col border-l border-zinc-200 bg-white shadow-lg sm:w-[28rem]\">\n\n      <div class=\"flex items-start justify-between gap-3 border-b border-zinc-200 px-5 py-4\">\n        <div class=\"min-w-0\">\n          <p class=\"text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase tabular-nums\">PO-24-1187</p>\n          <h2 id=\"drawer-title\" class=\"mt-0.5 truncate text-[16px]/6 font-semibold\">MS angles and plates — August lot</h2>\n          <p class=\"mt-1 truncate text-[12px]/4 text-zinc-600\">Sharma Extrusions · raised 04 Aug 2024 by Ritu Deshpande</p>\n        </div>\n        <button type=\"button\" @click=\"open = false\" aria-label=\"Close\"\n                class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-4\"></i>\n        </button>\n      </div>\n\n      <div class=\"flex-1 overflow-y-auto px-5 py-4\">\n        <dl class=\"divide-y divide-zinc-100\">\n          <div class=\"flex items-baseline justify-between gap-4 py-2.5\">\n            <dt class=\"text-[13px]/5 text-zinc-600\">Status</dt>\n            <dd class=\"text-[13px]/5 font-medium\"><span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[12px]/4 font-medium text-zinc-700\"><span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved</span></dd>\n          </div>\n          <div class=\"flex items-baseline justify-between gap-4 py-2.5\">\n            <dt class=\"text-[13px]/5 text-zinc-600\">Order value</dt>\n            <dd class=\"text-[13px]/5 font-medium tabular-nums\">₹18,42,000</dd>\n          </div>\n          <div class=\"flex items-baseline justify-between gap-4 py-2.5\">\n            <dt class=\"text-[13px]/5 text-zinc-600\">Payment terms</dt>\n            <dd class=\"text-[13px]/5 font-medium\">45 days from GRN</dd>\n          </div>\n          <div class=\"flex items-baseline justify-between gap-4 py-2.5\">\n            <dt class=\"text-[13px]/5 text-zinc-600\">Delivery by</dt>\n            <dd class=\"text-[13px]/5 font-medium tabular-nums\">28 Aug 2024</dd>\n          </div>\n          <div class=\"flex items-baseline justify-between gap-4 py-2.5\">\n            <dt class=\"text-[13px]/5 text-zinc-600\">Ship to</dt>\n            <dd class=\"text-right text-[13px]/5 font-medium\">Plant 2, Waluj MIDC</dd>\n          </div>\n        </dl>\n\n        <div class=\"mt-5 rounded-lg border border-zinc-200 px-4 py-3\">\n          <div class=\"flex items-baseline justify-between gap-3\">\n            <p class=\"text-[13px]/5 font-medium\">Received against GRN</p>\n            <p class=\"text-[13px]/5 font-medium tabular-nums\">7,800 / 12,000 kg</p>\n          </div>\n          <div class=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-zinc-100\">\n            <div class=\"h-full rounded-full bg-zinc-700\" style=\"width: 65%\"></div>\n          </div>\n          <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Last receipt GRN 1142 on 16 Aug 2024.</p>\n        </div>\n      </div>\n\n      <div class=\"flex flex-wrap justify-end gap-2 border-t border-zinc-200 bg-zinc-100 px-5 py-3\">\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Print order</button>\n        <button type=\"button\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Record GRN</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "filters",
          "name": "Filters",
          "html": "<div x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = true\"\n          class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"sliders-horizontal\" class=\"size-4\"></i>Filters\n    <span class=\"rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-1.5 text-[11px]/4 font-medium tabular-nums\">2</span>\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\" @keydown.escape.window=\"open = false\" @click.self=\"open = false\"\n       class=\"fixed inset-0 z-50 flex justify-end bg-zinc-900/40\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"filters-title\"\n         x-show=\"open\"\n         x-transition:enter=\"transition ease-out duration-200 motion-reduce:transition-none motion-reduce:duration-0\"\n         x-transition:enter-start=\"translate-x-full\"\n         x-transition:leave=\"transition ease-in duration-150 motion-reduce:transition-none motion-reduce:duration-0\"\n         x-transition:leave-end=\"translate-x-full\"\n         class=\"flex h-full w-full flex-col border-l border-zinc-200 bg-white shadow-lg sm:w-96\">\n\n      <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 px-5 py-4\">\n        <h2 id=\"filters-title\" class=\"text-[16px]/6 font-semibold\">Filter orders</h2>\n        <button type=\"button\" @click=\"open = false\" aria-label=\"Close\"\n                class=\"-mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-4\"></i>\n        </button>\n      </div>\n\n      <div class=\"flex-1 overflow-y-auto px-5 py-4\">\n        <div>\n          <label for=\"f-vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <select id=\"f-vendor\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n              <option>All vendors</option>\n              <option selected>Sharma Extrusions</option>\n              <option>Nashik Steel Traders</option>\n              <option>Gujarat Polymers Ltd</option>\n            </select>\n          </div>\n        </div>\n\n        <fieldset class=\"mt-4\">\n          <legend class=\"mb-2 text-[13px]/5 font-medium\">Status</legend>\n          <label class=\"flex items-center gap-2.5 text-[14px]/5\"><input type=\"checkbox\" checked class=\"size-4 rounded accent-zinc-700\">Open</label>\n          <label class=\"mt-2 flex items-center gap-2.5 text-[14px]/5\"><input type=\"checkbox\" class=\"size-4 rounded accent-zinc-700\">Approved</label>\n          <label class=\"mt-2 flex items-center gap-2.5 text-[14px]/5\"><input type=\"checkbox\" class=\"size-4 rounded accent-zinc-700\">Closed</label>\n        </fieldset>\n\n        <div class=\"mt-4\">\n          <label for=\"f-from\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Raised between</label>\n          <div class=\"flex flex-wrap items-center gap-2\">\n            <div class=\"min-w-[8rem] flex-1 rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n              <input id=\"f-from\" type=\"date\" value=\"2024-08-01\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n            </div>\n            <span class=\"text-[13px]/5 text-zinc-500\">to</span>\n            <div class=\"min-w-[8rem] flex-1 rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n              <input id=\"f-to\" aria-label=\"Raised to\" type=\"date\" value=\"2024-08-31\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n            </div>\n          </div>\n        </div>\n\n        <div class=\"mt-4\">\n          <label for=\"f-min\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Minimum value</label>\n          <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <span class=\"pl-3 text-[14px]/5 text-zinc-600\">₹</span>\n            <input id=\"f-min\" value=\"1,00,000\" class=\"w-full bg-transparent px-2 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n\n        <label class=\"mt-4 flex items-center gap-2.5 text-[14px]/5\">\n          <input type=\"checkbox\" checked class=\"size-4 rounded accent-zinc-700\">Only orders with a pending GRN\n        </label>\n      </div>\n\n      <div class=\"flex flex-wrap justify-end gap-2 border-t border-zinc-200 bg-zinc-100 px-5 py-3\">\n        <button type=\"button\" class=\"rounded-lg px-4 py-2 text-[13px]/5 font-medium text-zinc-900 hover:bg-white\">Reset</button>\n        <button type=\"button\" @click=\"open = false\"\n                class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Apply filters</button>\n      </div>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "badge",
      "name": "Badge",
      "category": "feedback",
      "description": "A small pill that labels a record — status, tag or count. Every pill is the same graphite shape; a 6px dot carries the colour.",
      "when_to_use": "One word about what a record is. If it needs a sentence it is not a badge, and if it does something when clicked it is a button wearing a pill.",
      "rules": [
        "The status mapping is fixed and lives in the dot: Open zinc-500, Approved amber-500, Overdue red-600, Closed emerald-600, Draft zinc-400. Approved is amber because it is waiting on someone; Closed is green because it is finished. Do not reinterpret it per screen.",
        "The pill itself never takes a hue. bg-zinc-200, ring-zinc-300, text-zinc-700 on all five — a column of tinted pills reads as a traffic light and stops meaning anything by the twelfth row.",
        "The fill is bg-zinc-200 with ring-zinc-300 — the chip fill, the same one an avatar takes. bg-zinc-100 is the surface fill, so a pill wearing it is the identical colour as the selected row underneath it and vanishes into it.",
        "Solid pills take no ring. A graphite or red count badge already has an edge, and a pale ring around a dark pill reads as a rendering fault.",
        "Keep one label per state across the whole app. \"Open\" in one table and \"Pending\" in another reads as two different things.",
        "Counts are numbers — tabular-nums, and cap the display at 99+ so the pill cannot widen and reflow its row.",
        "The small size is for table cells and dense toolbars only. Everywhere else is the default size; there is no third."
      ],
      "anatomy": [
        {
          "part": "Pill",
          "description": "rounded-full, bg-zinc-200, the same on every state. It is the shape that says \"this is a state\"; it is not what says which one."
        },
        {
          "part": "Ring",
          "description": "ring-zinc-300, one step deeper than the fill. It is a ring rather than a border so adding it reflows nothing."
        },
        {
          "part": "Dot",
          "description": "The 6px marker that carries the state. Not optional on a status pill — without it every state looks the same."
        },
        {
          "part": "Label",
          "description": "One word, sentence case, from a fixed vocabulary. Not a sentence and not a number with units."
        },
        {
          "part": "Count",
          "description": "tabular-nums, capped at 99+ so the pill cannot grow and reflow its row."
        },
        {
          "part": "Remove",
          "description": "Only on tags the user applied. A status is not removable, because it describes the record rather than decorating it."
        }
      ],
      "behaviour": [
        "One label per state across the whole application. \"Open\" in one table and \"Pending\" in another reads as two different things.",
        "The dot colour comes from the locked mapping and is not reinterpreted per screen. The pill around it never changes.",
        "Counts cap at 99+ rather than widening, so a row does not reflow when a number crosses a hundred.",
        "A removable tag removes on click without a confirmation — it is cheap to reapply.",
        "A badge that filters is a real button and shows its selected state as a solid fill, not as a slightly darker tint nobody can see.",
        "Badges do not wrap. A row of them scrolls or truncates; a badge broken across two lines stops reading as one object."
      ],
      "accessibility": [
        "The label is real text inside the pill, so colour is never the only carrier of the state.",
        "The dot is decorative and aria-hidden; the word beside it is the information.",
        "A count badge on a control is included in that control's accessible name — \"Notifications, 99 or more unread\" — because \"bell, 99+\" is not a sentence.",
        "A remove control is a real button with aria-label naming its tag.",
        "A filter badge is a button with aria-pressed, so its on state is announced and not merely filled in.",
        "Status pills are not interactive and are not focusable, so they do not appear in the Tab order as dead stops."
      ],
      "related": [
        "table",
        "alert",
        "avatar"
      ],
      "variants": [
        {
          "id": "status",
          "name": "Status pills",
          "html": "<!-- The locked mapping. One pill class on all five; the dot is the only thing\n     that differs. Copy these verbatim, and do not invent a sixth colour for a\n     sixth state — add the state to this list or reuse Open. -->\n<div class=\"flex flex-wrap items-center gap-2\">\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Closed\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full bg-zinc-400\" aria-hidden=\"true\"></span>Draft\n  </span>\n</div>"
        },
        {
          "id": "inline",
          "name": "Inline, without the pill",
          "html": "<!-- The same locked dot with no pill around it. Use this where there is one\n     status and nothing to line it up with — a detail header, a card meta line.\n     The pill exists to give a column of states a shared left edge; one state on\n     its own has no column, so the shape is doing nothing but adding weight. -->\n<div class=\"max-w-md rounded-xl border border-zinc-200 bg-white p-4\">\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <h3 class=\"text-[16px]/6 font-semibold\">PO-24-1187</h3>\n    <span class=\"text-[13px]/5 tabular-nums text-zinc-600\">₹4,82,000</span>\n  </div>\n  <dl class=\"mt-3 space-y-1.5 text-[13px]/5\">\n    <div class=\"flex gap-3\">\n      <dt class=\"w-20 shrink-0 text-zinc-600\">Supplier</dt>\n      <dd>Sharma Extrusions</dd>\n    </div>\n    <div class=\"flex gap-3\">\n      <dt class=\"w-20 shrink-0 text-zinc-600\">Status</dt>\n      <dd class=\"inline-flex items-center gap-2 font-medium text-zinc-700\">\n        <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n      </dd>\n    </div>\n    <div class=\"flex gap-3\">\n      <dt class=\"w-20 shrink-0 text-zinc-600\">Raised</dt>\n      <dd class=\"tabular-nums\">14 Aug 2026</dd>\n    </div>\n  </dl>\n</div>"
        },
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- Two sizes and no third. Small is for table cells and dense toolbars, where\n     the default pill makes the row taller than its text needs. The dot stays\n     1.5 at both sizes — shrinking it is how a state stops being visible. -->\n<div class=\"flex flex-wrap items-center gap-4\">\n  <div class=\"flex items-center gap-2\">\n    <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n      <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n    </span>\n    <span class=\"text-[12px]/4 text-zinc-600\">Small — inside table rows</span>\n  </div>\n  <div class=\"flex items-center gap-2\">\n    <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n      <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n    </span>\n    <span class=\"text-[12px]/4 text-zinc-600\">Default — everywhere else</span>\n  </div>\n</div>"
        },
        {
          "id": "icon",
          "name": "With an icon",
          "html": "<!-- An icon belongs on a badge that names a kind, not a state. States already\n     have the five colours; a kind has nothing else to distinguish it. -->\n<div class=\"flex flex-wrap items-center gap-2\">\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pr-2.5 pl-2 text-[12px]/4 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"mail\" class=\"size-3 text-zinc-600\"></i>Email\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pr-2.5 pl-2 text-[12px]/4 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"phone\" class=\"size-3 text-zinc-600\"></i>Phone\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pr-2.5 pl-2 text-[12px]/4 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"map-pin\" class=\"size-3 text-zinc-600\"></i>Site visit\n  </span>\n  <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pr-2.5 pl-2 text-[12px]/4 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"paperclip\" class=\"size-3 text-zinc-600\"></i>3 files\n  </span>\n</div>"
        },
        {
          "id": "filter",
          "name": "Filter chips",
          "html": "<!-- A badge that does something is a button. Selected is a solid fill, because\n     a marginally darker tint is not a state anyone can see across a toolbar.\n     aria-pressed carries the same fact to a screen reader. -->\n<div class=\"flex flex-wrap items-center gap-2\" x-data=\"{ on: ['overdue'] }\">\n  <template x-for=\"f in [{ id: 'mine', label: 'My orders' }, { id: 'overdue', label: 'Overdue' }, { id: 'unapproved', label: 'Awaiting approval' }, { id: 'month', label: 'This month' }]\" :key=\"f.id\">\n    <button type=\"button\"\n            @click=\"on = on.includes(f.id) ? on.filter(x => x !== f.id) : [...on, f.id]\"\n            :aria-pressed=\"on.includes(f.id)\"\n            class=\"rounded-full px-2.5 py-1 text-[12px]/4 font-medium\"\n            :class=\"on.includes(f.id)\n              ? 'bg-zinc-700 text-white hover:bg-zinc-800'\n              : 'bg-white text-zinc-600 ring-1 ring-inset ring-zinc-200 hover:bg-zinc-100'\">\n      <span x-text=\"f.label\"></span>\n    </button>\n  </template>\n  <button type=\"button\" x-show=\"on.length\" x-cloak @click=\"on = []\"\n          class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear</button>\n</div>"
        },
        {
          "id": "removable",
          "name": "Removable tag",
          "html": "<!-- Only for tags the user applied. A status is not removable — it describes\n     the record rather than decorating it, and an x on it promises an edit that\n     is not going to happen. -->\n<div class=\"flex flex-wrap items-center gap-2\" x-data=\"{ tags: ['Sharma Extrusions', 'Open', '₹1,00,000+'] }\">\n  <template x-for=\"tag in tags\" :key=\"tag\">\n    <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pr-1 pl-2.5 text-[12px]/4 font-medium ring-1 ring-inset ring-zinc-300\">\n      <span x-text=\"tag\"></span>\n      <button type=\"button\" @click=\"tags = tags.filter(t => t !== tag)\" :aria-label=\"'Remove ' + tag\"\n              class=\"flex size-4 items-center justify-center rounded-full text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n        <i data-lucide=\"x\" class=\"size-3\"></i>\n      </button>\n    </span>\n  </template>\n  <button type=\"button\" x-show=\"tags.length\" x-cloak @click=\"tags = []\"\n          class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear all</button>\n  <p x-show=\"!tags.length\" x-cloak class=\"text-[12px]/4 text-zinc-600\">No filters applied.</p>\n</div>"
        },
        {
          "id": "count",
          "name": "Count badge",
          "html": "<!-- Counts are numbers: tabular-nums, and capped at 99+ so the pill cannot\n     widen and shove the rest of the toolbar sideways. The count is part of the\n     control's accessible name, not a separate announcement.\n\n     A count is not a state, so it takes no hue — the word next to it already\n     says \"Overdue\". Solid graphite is for the one badge that must be seen from\n     across the toolbar, and solid shapes take no ring. -->\n<div class=\"flex flex-wrap items-center gap-4\">\n  <button type=\"button\" class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    Pending approval\n    <span class=\"rounded-full bg-zinc-200 px-1.5 text-[11px]/4 font-medium tabular-nums ring-1 ring-inset ring-zinc-300\">12</span>\n  </button>\n\n  <button type=\"button\" class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    Overdue\n    <span class=\"rounded-full bg-zinc-200 px-1.5 text-[11px]/4 font-medium tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\">3</span>\n  </button>\n\n  <button type=\"button\" aria-label=\"Notifications, 99 or more unread\"\n          class=\"relative flex size-9 items-center justify-center rounded-lg border border-zinc-200 bg-white hover:bg-zinc-100\">\n    <i data-lucide=\"bell\" class=\"size-4\"></i>\n    <span class=\"absolute -top-1.5 -right-1.5 rounded-full bg-zinc-700 px-1.5 text-[11px]/4 font-medium tabular-nums text-white\">99+</span>\n  </button>\n</div>"
        },
        {
          "id": "table",
          "name": "In a table",
          "html": "<!-- Where a status pill actually lives, and the reason the pill is graphite.\n     Read the status column top to bottom: four identical shapes, and the eye\n     goes straight to the red dot. Tint the pills instead and the column becomes\n     a stripe of colour with nothing standing out of it.\n\n     Supplier and Value drop below sm rather than the table scrolling sideways —\n     nothing scrolls sideways on a phone, and table-fixed with declared widths\n     has a minimum that a phone cannot meet. The full small-screen treatment,\n     where rows become stacked cards, belongs to the table component. -->\n<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full table-fixed text-left text-[13px]/5\">\n    <thead class=\"border-b border-zinc-200 bg-zinc-100 text-[11px]/4 tracking-wider text-zinc-600 uppercase\">\n      <tr>\n        <th scope=\"col\" class=\"px-4 py-2 font-medium sm:w-32\">Order</th>\n        <th scope=\"col\" class=\"hidden px-4 py-2 font-medium sm:table-cell\">Supplier</th>\n        <th scope=\"col\" class=\"px-4 py-2 font-medium sm:w-28\">Status</th>\n        <th scope=\"col\" class=\"hidden px-4 py-2 text-right font-medium sm:table-cell sm:w-32\">Value</th>\n      </tr>\n    </thead>\n    <tbody>\n        <tr class=\"border-b border-zinc-100\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n          <td class=\"hidden truncate px-4 py-2.5 sm:table-cell\">Sharma Extrusions</td>\n          <td class=\"px-4 py-2.5\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n            </span>\n          </td>\n          <td class=\"hidden px-4 py-2.5 text-right tabular-nums sm:table-cell\">₹4,82,000</td>\n        </tr>\n        <tr class=\"border-b border-zinc-100 bg-zinc-100\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1186</td>\n          <td class=\"hidden truncate px-4 py-2.5 sm:table-cell\">Konkan Fabricators</td>\n          <td class=\"px-4 py-2.5\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n            </span>\n          </td>\n          <td class=\"hidden px-4 py-2.5 text-right tabular-nums sm:table-cell\">₹1,15,400</td>\n        </tr>\n        <tr class=\"border-b border-zinc-100\">\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1185</td>\n          <td class=\"hidden truncate px-4 py-2.5 sm:table-cell\">Deshpande Traders</td>\n          <td class=\"px-4 py-2.5\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n            </span>\n          </td>\n          <td class=\"hidden px-4 py-2.5 text-right tabular-nums sm:table-cell\">₹96,750</td>\n        </tr>\n        <tr>\n          <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1184</td>\n          <td class=\"hidden truncate px-4 py-2.5 sm:table-cell\">Qureshi Metals</td>\n          <td class=\"px-4 py-2.5\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Closed\n            </span>\n          </td>\n          <td class=\"hidden px-4 py-2.5 text-right tabular-nums sm:table-cell\">₹2,30,000</td>\n        </tr>\n      </tbody>\n  </table>\n</div>"
        },
        {
          "id": "django",
          "name": "Django status field",
          "html": "<!-- Because the pill is identical on every state, the template needs one\n     lookup and it returns a single colour. Put it in one place — a filter over\n     the field's raw value — never repeated per template. Repeating it is\n     exactly how one screen ends up amber and another green for the same record.\n\n     # templatetags/ui.py\n     DOT = {\n         'open':     'bg-zinc-400',\n         'approved': 'bg-amber-500',\n         'overdue':  'bg-red-600',\n         'closed':   'bg-emerald-600',\n         'draft':    'bg-zinc-300',\n     }\n\n     @register.filter\n     def status_dot(value):\n         return DOT.get(value, DOT['open'])\n\n     The label comes from get_status_display, so it follows the model's choices\n     and reads identically on every screen. -->\n{% load ui %}\n<span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n  <span class=\"size-1.5 rounded-full {{ order.status|status_dot }}\" aria-hidden=\"true\"></span>\n  {{ order.get_status_display }}\n</span>"
        }
      ]
    },
    {
      "id": "tooltip",
      "name": "Tooltip",
      "category": "feedback",
      "description": "A dark bubble on hover, done with group-hover alone — no JS, no Alpine.",
      "when_to_use": "Naming an icon-only button, or showing the full text of a cell that had to be truncated. Never put anything the user must read in a tooltip; it is invisible on touch and to keyboard users.",
      "rules": [
        "The tooltip is not a label. Keep aria-label on the button as well, or screen readers get nothing.",
        "pointer-events-none on the bubble, otherwise it eats the hover of whatever sits under it.",
        "The wrapper needs relative and group; the bubble positions against it."
      ],
      "anatomy": [
        {
          "part": "Wrapper",
          "description": "relative and group. The bubble positions against this, so it has to exist."
        },
        {
          "part": "Trigger",
          "description": "The icon button or the truncated cell the tooltip belongs to."
        },
        {
          "part": "Bubble",
          "description": "A zinc-900 panel appearing on group-hover, pointer-events-none so it does not eat the hover beneath it."
        },
        {
          "part": "Arrow",
          "description": "Optional. A rotated square tucked under the bubble's edge."
        }
      ],
      "behaviour": [
        "Hover only, with no JavaScript at all — group-hover does the whole job.",
        "It is invisible on touch and to the keyboard, which is why nothing the user must read may live in one.",
        "The bubble never intercepts the pointer; without pointer-events-none it flickers as the cursor crosses it.",
        "It appears above the trigger by default and flips below only when there is no room.",
        "A tooltip on a truncated cell shows the full text, which means the text must exist in the markup rather than being fetched."
      ],
      "accessibility": [
        "The tooltip is not a label. The button keeps its aria-label, or a screen reader gets nothing at all.",
        "The bubble is aria-hidden, because its text is already available through the trigger's accessible name.",
        "No essential information lives only in a tooltip — it cannot be reached by touch or by keyboard.",
        "The trigger stays a real button, so it is focusable even though the bubble will not appear on focus.",
        "Truncated text remains available to a screen reader in full, since truncation is visual only."
      ],
      "related": [
        "button",
        "badge",
        "table"
      ],
      "variants": [
        {
          "id": "icon",
          "name": "On an icon button",
          "html": "<div class=\"group relative inline-flex\">\n  <button type=\"button\" aria-label=\"Duplicate order\"\n          class=\"flex size-9 items-center justify-center rounded-lg border border-zinc-200 bg-white hover:bg-zinc-100\">\n    <i data-lucide=\"copy\" class=\"size-4\"></i>\n  </button>\n  <span class=\"pointer-events-none absolute bottom-full left-1/2 z-40 mb-2 -translate-x-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white opacity-0 transition-opacity group-hover:opacity-100\">\n    Duplicate order\n  </span>\n</div>"
        },
        {
          "id": "truncated",
          "name": "On a truncated table cell",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full text-left text-[13px]/5\">\n    <thead class=\"border-b border-zinc-200 bg-zinc-100 text-[11px]/4 tracking-wider text-zinc-600 uppercase\">\n      <tr>\n        <th scope=\"col\" class=\"px-4 py-2 font-medium\">Order</th>\n        <th scope=\"col\" class=\"px-4 py-2 font-medium\">Description</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"max-w-[14rem] px-4 py-2.5\">\n          <span class=\"group relative block\">\n            <span class=\"block truncate text-zinc-600\">MS angles 50×50×6 and plates 10 mm — Waluj plant, August lot</span>\n            <span class=\"pointer-events-none absolute top-full left-0 z-40 mt-1 max-w-xs rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 text-white opacity-0 transition-opacity group-hover:opacity-100\">\n              MS angles 50×50×6 and plates 10 mm — Waluj plant, August lot\n            </span>\n          </span>\n        </td>\n      </tr>\n      <tr>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1163</td>\n        <td class=\"max-w-[14rem] px-4 py-2.5\">\n          <span class=\"group relative block\">\n            <span class=\"block truncate text-zinc-600\">HR coil 2.5 mm × 1250 mm — Nashik Steel Traders, part shipment</span>\n            <span class=\"pointer-events-none absolute top-full left-0 z-40 mt-1 max-w-xs rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 text-white opacity-0 transition-opacity group-hover:opacity-100\">\n              HR coil 2.5 mm × 1250 mm — Nashik Steel Traders, part shipment\n            </span>\n          </span>\n        </td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        }
      ]
    },
    {
      "id": "hovercard",
      "name": "Hovercard",
      "category": "feedback",
      "description": "A card of record detail that opens under a link on hover or focus, after a deliberate delay, and stays open long enough to be read and clicked.",
      "when_to_use": "Previewing the record behind a link without leaving the register — the vendor on a PO row, the person who approved it, the order a GRN was raised against. Never for anything the user has to see: hover does not exist on a touch screen, so every fact in the card must also be at the far end of the link. To name an icon-only button, use a tooltip.",
      "rules": [
        "This is not a tooltip and must not be built like one. A tooltip is the accessible name of a control, rendered as a zinc-900 bubble, aria-hidden, pointer-events-none, and holding nothing anyone has to read. A hovercard is a white rounded-xl panel with a zinc-200 border and a shadow, it is interactive, and it holds content — which is exactly why it needs the delays, the keyboard route and the dismiss that a tooltip does not.",
        "Open on a delay of 300–500ms. Without one, dragging the pointer diagonally across a column of twelve PO links opens and closes twelve cards on the way to the scrollbar, and the register strobes.",
        "Close on a delay of 150–250ms as well. The pointer has to cross from the trigger into the panel, and it is off both for a frame or two while it does. No close delay means the card can never be reached, only glimpsed — this is WCAG 1.4.13 hoverable, not a nicety.",
        "The gap between trigger and panel is padding on the positioned wrapper, never a margin. A margin is dead space that belongs to nothing, so mouseleave fires as the pointer crosses it; padding on the wrapper keeps the pointer inside the subtree the whole way and the hover never breaks.",
        "Never pointer-events-none on the panel. A hovercard is meant to be clicked into, which is what makes the hover bridge and the close delay load-bearing rather than polish — a tooltip gets away without them precisely because nothing in it can be reached.",
        "Escape closes it, and it is bound on window rather than on the wrapper. A card opened by the pointer has no focus inside it, so a wrapper-scoped keydown never fires and there is no way to dismiss it at all — WCAG 1.4.13 dismissible.",
        "Focus opens it too, through @focusin and @focusout on the wrapper. focus and blur do not bubble, so binding those on the wrapper silently does nothing; and a hovercard with no focus route is a card the keyboard can never see, which is why a tooltip may not hold content and this may.",
        "Open on focus with no delay. The delay exists to survive a pointer travelling across a column; a Tab key lands on one trigger deliberately and waiting 350ms after it reads as lag.",
        "One card open at a time, announced with a window CustomEvent carrying the root element — every other card closes when it hears one that is not itself. The delays alone very nearly serialise it, and very nearly is how two cards end up overlapping on a slow render.",
        "The table wrapper cannot be overflow-hidden or the panel is clipped at the first row it opens on. Round the header cells instead. The same trap is any ancestor carrying transform, filter or will-change: it becomes the containing block, so a position:fixed panel anchors to the row rather than to the viewport, and truncate on the cell clips the panel too.",
        "left-0 anchors the panel to the trigger, so it only survives 390px while the trigger starts its own line — a name halfway through a sentence puts a 320px panel half off the screen and the page scrolls sideways. Either give the trigger the start of a line, which is what a table cell and a labelled field already do, or clamp the panel with a translate as the placement variant does. Never let it size itself down to fit.",
        "Fetch at most once per trigger and keep the result. hx-trigger fires the request, a loaded flag stops the second one, and hx-sync=\"this:drop\" throws away a request that arrives while one is in flight. A card that refetches on every pass of the pointer turns a 50-row register into a load test.",
        "On touch, guard the hover handlers with matchMedia('(hover: hover) and (pointer: fine)'). A tap fires a synthetic mouseenter, so an unguarded card opens on the tap that was meant to follow the link, and then nothing closes it."
      ],
      "anatomy": [
        {
          "part": "Wrapper",
          "description": "relative inline-block. It owns the state and every handler, because mouseleave has to mean \"left the trigger and the panel\", and only their common parent knows that."
        },
        {
          "part": "Trigger",
          "description": "A real link to the record, or a real button. It works with the card never opening, and it keeps its own accessible name."
        },
        {
          "part": "Bridge",
          "description": "pt-2 or pb-2 on the positioned wrapper, not mt-2 on the panel. It is the strip the pointer crosses, and it has to belong to the component."
        },
        {
          "part": "Panel",
          "description": "The positioned wrapper's child: w-80, max-w-[calc(100vw_-_1.5rem)], rounded-xl, white, border-zinc-200, shadow-lg, z-40. A card that floats, not a tooltip bubble."
        },
        {
          "part": "Identity",
          "description": "The first line of the panel — the record number or the person's name — with the status pill or presence dot beside it."
        },
        {
          "part": "Figures",
          "description": "A dl of two to four facts, dt in zinc-600, dd in zinc-900 and tabular-nums. More than four and the thing being previewed is a page."
        },
        {
          "part": "Action",
          "description": "At most one, at the foot. It exists on the destination page as well, so nothing is lost when the card never opens."
        }
      ],
      "behaviour": [
        "Hover opens after about 350ms and closes about 200ms after the pointer has left both the trigger and the panel, so the pointer can travel between them and a pass across the column opens nothing.",
        "Focus opens it immediately, tabbing into the panel keeps it open, and tabbing out of the panel closes it — the card a keyboard user can see is the card they can reach.",
        "Escape closes it from anywhere on the page and leaves focus where it was. Nothing closes it on a timer: it stays until the pointer leaves, focus leaves or Escape is pressed.",
        "Only one card is open at a time. Opening one closes the rest through a window event, with no store to keep in sync.",
        "Fetched content is requested on first open and never again. A failed fetch is the exception — it leaves the flag unset, so the next open retries.",
        "Near the bottom of the viewport it flips above the trigger, and near an edge it shifts sideways to stay inside it. It never resizes to fit, because a card that changes shape by position is unreadable at speed.",
        "On a touch device hover never fires. The card opens from an explicit control or not at all, and the link underneath still goes to the record."
      ],
      "accessibility": [
        "The trigger is a real link or button with its own accessible name, and the card is supplementary. Everything in it is on the page the link goes to, because touch has no hover and neither does a screen reader.",
        "Never aria-describedby the panel. describedby flattens the whole card — heading, pill, four figures and a button — into one run-on string read after the link's name, repeated on every focus, and the button inside it stops being a button.",
        "No aria-expanded on a link trigger. Expanded and collapsed describe a control that reveals content when it is activated, and this one navigates: a reader told \"collapsed\" will press Enter and leave the page. aria-expanded plus aria-controls is correct only when the trigger is a button whose sole job is opening the card, as in the touch variant.",
        "The panel is not aria-hidden. It sits immediately after the trigger in the DOM and opens on focusin, so a keyboard user tabs from the link straight into what is now on the screen; hiding a visible, focusable panel from the tree strands focus somewhere the reader is told does not exist.",
        "While closed, x-show sets display:none, so nothing inside the panel is a tab stop. Fifty rows of hovercards add no tab stops to the register at all.",
        "Escape dismisses without moving the pointer, the card stays open while the pointer is over it, and it never disappears on its own — the three halves of WCAG 1.4.13 for content shown on hover.",
        "Status inside the card follows the fixed dot mapping and the dot is aria-hidden; the word beside it is what carries the state, so the meaning does not live in a colour."
      ],
      "related": [
        "tooltip",
        "dropdown",
        "card"
      ],
      "variants": [
        {
          "id": "default",
          "name": "On a record link",
          "html": "<!-- The wrapper around the table is not overflow-hidden. That is the first\n     thing that goes wrong with a hovercard in a register: the panel is clipped\n     at the row it opens on and looks like a rendering bug. Round the header\n     cells instead, and leave the panel free to escape.\n\n     Two rows carry a card so the delay is testable: drag the pointer across\n     both and nothing should open. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white\">\n  <table class=\"w-full text-left text-[13px]/5\">\n    <thead class=\"border-b border-zinc-200 bg-zinc-100 text-[11px]/4 tracking-wider text-zinc-600 uppercase\">\n      <tr>\n        <th scope=\"col\" class=\"rounded-tl-xl px-4 py-2 font-medium\">Order</th>\n        <th scope=\"col\" class=\"hidden px-4 py-2 font-medium sm:table-cell\">Vendor</th>\n        <th scope=\"col\" class=\"rounded-tr-xl px-4 py-2 text-right font-medium\">Value</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <span class=\"relative inline-block\"\n                x-data=\"{\n                  open: false, timer: 0,\n                  show(d = 350) {\n                    clearTimeout(this.timer);\n                    this.timer = setTimeout(() => {\n                      this.open = true;\n                      window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n                    }, d);\n                  },\n                  hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n                  shut() { clearTimeout(this.timer); this.open = false; }\n                }\"\n                @mouseenter=\"show()\" @mouseleave=\"hide()\"\n                @focusin=\"show(0)\" @focusout=\"hide()\"\n                @keydown.escape.window=\"shut()\"\n                @hovercard-open.window=\"if ($event.detail !== $root) shut()\">\n\n            <a href=\"/orders/1187/\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\">PO-24-1187</a>\n\n            <!-- pt-2 on this wrapper, never mt-2 on the panel: the gap has to\n                 belong to the component or the pointer leaves it crossing -->\n            <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n                 class=\"absolute top-full left-0 z-40 pt-2\">\n              <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n                <div class=\"flex items-start justify-between gap-3\">\n                  <div class=\"min-w-0\">\n                    <p class=\"truncate text-[14px]/5 font-semibold tabular-nums\">PO-24-1187</p>\n                    <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd</p>\n                  </div>\n                  <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n                    <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n                  </span>\n                </div>\n                <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n                    <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹18,42,000</dd>\n                  </div>\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n                    <dd class=\"text-[12px]/4 tabular-nums\">28 Aug 2026</dd>\n                  </div>\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Received</dt>\n                    <dd class=\"text-[12px]/4 tabular-nums\">2 of 3 GRNs</dd>\n                  </div>\n                </dl>\n                <p class=\"mt-3 border-t border-zinc-100 pt-3 text-[12px]/4 text-zinc-500 tabular-nums\">Raised by Ritu Deshpande · 12 Aug 2026</p>\n              </div>\n            </div>\n          </span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 text-zinc-600 sm:table-cell\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n      </tr>\n\n      <tr>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"relative inline-block\"\n                x-data=\"{\n                  open: false, timer: 0,\n                  show(d = 350) {\n                    clearTimeout(this.timer);\n                    this.timer = setTimeout(() => {\n                      this.open = true;\n                      window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n                    }, d);\n                  },\n                  hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n                  shut() { clearTimeout(this.timer); this.open = false; }\n                }\"\n                @mouseenter=\"show()\" @mouseleave=\"hide()\"\n                @focusin=\"show(0)\" @focusout=\"hide()\"\n                @keydown.escape.window=\"shut()\"\n                @hovercard-open.window=\"if ($event.detail !== $root) shut()\">\n\n            <a href=\"/orders/1191/\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\">PO-24-1191</a>\n\n            <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n                 class=\"absolute top-full left-0 z-40 pt-2\">\n              <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n                <div class=\"flex items-start justify-between gap-3\">\n                  <div class=\"min-w-0\">\n                    <p class=\"truncate text-[14px]/5 font-semibold tabular-nums\">PO-24-1191</p>\n                    <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Nashik Steel Traders</p>\n                  </div>\n                  <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n                    <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n                  </span>\n                </div>\n                <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n                    <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹6,04,750</dd>\n                  </div>\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n                    <dd class=\"text-[12px]/4 tabular-nums\">02 Aug 2026</dd>\n                  </div>\n                  <div class=\"flex items-baseline justify-between gap-3\">\n                    <dt class=\"text-[12px]/4 text-zinc-600\">Received</dt>\n                    <dd class=\"text-[12px]/4 tabular-nums\">0 of 2 GRNs</dd>\n                  </div>\n                </dl>\n                <p class=\"mt-3 border-t border-zinc-100 pt-3 text-[12px]/4 text-zinc-500 tabular-nums\">Raised by Sanjay More · 18 Aug 2026</p>\n              </div>\n            </div>\n          </span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 text-zinc-600 sm:table-cell\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹6,04,750</td>\n      </tr>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "vendor",
          "name": "Vendor card",
          "html": "<!-- The richest a hovercard is allowed to get: who they are, what state the\n     relationship is in, four figures and one way in. Past that the answer is\n     the vendor page, which is where the link already goes.\n\n     The initials circle is a tinted shape, so it carries its ring, and it is\n     aria-hidden because the name is written beside it.\n\n     The pill says Active, not Approved. The five fixed statuses describe what a\n     record is doing, and Approved is amber there because it means waiting on\n     someone; a vendor on the approved list is not waiting for anything. Reusing\n     the word with a different dot is how one screen ends up contradicting the\n     next, so the standing of a person or a party takes emerald the way the\n     presence dot does, and the record words are left to records. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Vendor</p>\n  <span class=\"relative mt-1 inline-block\"\n        x-data=\"{\n          open: false, timer: 0,\n          show(d = 350) {\n            clearTimeout(this.timer);\n            this.timer = setTimeout(() => {\n              this.open = true;\n              window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n            }, d);\n          },\n          hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n          shut() { clearTimeout(this.timer); this.open = false; }\n        }\"\n        @mouseenter=\"show()\" @mouseleave=\"hide()\"\n        @focusin=\"show(0)\" @focusout=\"hide()\"\n        @keydown.escape.window=\"shut()\"\n        @hovercard-open.window=\"if ($event.detail !== $root) shut()\">\n\n    <a href=\"/vendors/142/\" class=\"font-medium text-zinc-900 underline underline-offset-2\">Gujarat Polymers Ltd</a>\n\n    <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n         class=\"absolute top-full left-0 z-40 pt-2\">\n      <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n        <div class=\"flex items-start gap-3\">\n          <span class=\"flex size-10 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-[13px]/5 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\" aria-hidden=\"true\">GP</span>\n          <div class=\"min-w-0 flex-1\">\n            <p class=\"truncate text-[14px]/5 font-semibold\">Gujarat Polymers Ltd</p>\n            <div class=\"mt-1 flex flex-wrap items-center gap-2\">\n              <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n                <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Active\n              </span>\n              <span class=\"text-[12px]/4 text-zinc-500 tabular-nums\">V-0142 · Vapi</span>\n            </div>\n          </div>\n        </div>\n\n        <dl class=\"mt-3 grid grid-cols-2 gap-x-4 gap-y-2 border-t border-zinc-100 pt-3\">\n          <div>\n            <dt class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Open orders</dt>\n            <dd class=\"mt-0.5 text-[13px]/5 font-medium tabular-nums\">4</dd>\n          </div>\n          <div>\n            <dt class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Outstanding</dt>\n            <dd class=\"mt-0.5 text-[13px]/5 font-medium tabular-nums\">₹42,18,500</dd>\n          </div>\n          <div>\n            <dt class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Terms</dt>\n            <dd class=\"mt-0.5 text-[13px]/5 tabular-nums\">45 days</dd>\n          </div>\n          <div>\n            <dt class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">On time</dt>\n            <dd class=\"mt-0.5 text-[13px]/5 tabular-nums\">92%</dd>\n          </div>\n        </dl>\n\n        <div class=\"mt-3 flex items-center justify-between gap-3 border-t border-zinc-100 pt-3\">\n          <span class=\"truncate text-[12px]/4 text-zinc-500 tabular-nums\">GSTIN 24AABCG1234F1Z5</span>\n          <a href=\"/vendors/142/\" class=\"inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[12px]/4 font-medium hover:bg-zinc-100\">\n            Open vendor<i data-lucide=\"arrow-right\" class=\"size-3.5 text-zinc-600\"></i>\n          </a>\n        </div>\n      </div>\n    </div>\n  </span>\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500 tabular-nums\">3 lines short · delivery due 28 Aug 2026</p>\n</div>"
        },
        {
          "id": "user",
          "name": "Person card",
          "html": "<!-- The trigger is the avatar and the name together inside one link, so the\n     tab order gets one stop and not two. The presence dot says something about\n     the person and never about a record — record state belongs on a badge,\n     where the colour mapping is fixed.\n\n     The label sits above the trigger rather than beside it. left-0 anchors the\n     panel to the trigger, so a trigger that starts halfway along a line puts a\n     320px panel halfway off a 390px screen; starting its own line is the\n     cheapest way to be sure it does not. Where the trigger genuinely has to sit\n     inside a sentence, use the clamp from the placement variant. -->\n<div>\n  <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Approved by</p>\n  <span class=\"relative mt-1 inline-block\"\n        x-data=\"{\n          open: false, timer: 0,\n          show(d = 350) {\n            clearTimeout(this.timer);\n            this.timer = setTimeout(() => {\n              this.open = true;\n              window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n            }, d);\n          },\n          hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n          shut() { clearTimeout(this.timer); this.open = false; }\n        }\"\n        @mouseenter=\"show()\" @mouseleave=\"hide()\"\n        @focusin=\"show(0)\" @focusout=\"hide()\"\n        @keydown.escape.window=\"shut()\"\n        @hovercard-open.window=\"if ($event.detail !== $root) shut()\">\n\n    <a href=\"/people/ritu-deshpande/\" class=\"inline-flex items-center gap-2 rounded-lg py-0.5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <span class=\"flex size-6 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\" aria-hidden=\"true\">RD</span>\n      <span class=\"font-medium text-zinc-900 underline underline-offset-2\">Ritu Deshpande</span>\n    </a>\n\n    <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n         class=\"absolute top-full left-0 z-40 pt-2\">\n      <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n        <div class=\"flex items-start gap-3\">\n          <span class=\"relative shrink-0\">\n            <span class=\"flex size-10 items-center justify-center rounded-full bg-zinc-200 text-[13px]/5 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\" aria-hidden=\"true\">RD</span>\n            <span class=\"absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full bg-emerald-600 ring-2 ring-white\" aria-hidden=\"true\"></span>\n          </span>\n          <div class=\"min-w-0\">\n            <p class=\"truncate text-[14px]/5 font-semibold\">Ritu Deshpande</p>\n            <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Purchase Manager · Waluj plant</p>\n            <p class=\"mt-0.5 text-[12px]/4 text-zinc-500\">Online</p>\n          </div>\n        </div>\n\n        <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n          <div class=\"flex items-baseline justify-between gap-3\">\n            <dt class=\"text-[12px]/4 text-zinc-600\">Approves up to</dt>\n            <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹5,00,000</dd>\n          </div>\n          <div class=\"flex items-baseline justify-between gap-3\">\n            <dt class=\"text-[12px]/4 text-zinc-600\">Waiting on her</dt>\n            <dd class=\"text-[12px]/4 tabular-nums\">3 orders</dd>\n          </div>\n        </dl>\n\n        <div class=\"mt-3 flex items-center justify-between gap-3 border-t border-zinc-100 pt-3\">\n          <span class=\"truncate text-[12px]/4 text-zinc-500 tabular-nums\">+91 98220 41187</span>\n          <a href=\"mailto:ritu.deshpande@konspec.in\" class=\"inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[12px]/4 font-medium hover:bg-zinc-100\">\n            <i data-lucide=\"mail\" class=\"size-3.5 text-zinc-600\"></i>Email\n          </a>\n        </div>\n      </div>\n    </div>\n  </span>\n</div>"
        },
        {
          "id": "placement",
          "name": "Flipping and clamping",
          "html": "<!-- Two triggers, one at each end of the row. The right-hand one would hang\n     off the screen if the panel simply opened left-aligned, and near the foot\n     of a long register both of them would open below the fold.\n\n     place() runs inside $nextTick, after x-show has restored display, which is\n     the first moment the panel can be measured and still before the browser\n     paints — so the flip costs no visible jump. It shifts the panel with a\n     translate rather than changing its width: a card that is a different shape\n     depending on where it opened cannot be read at speed. -->\n<div class=\"flex items-center justify-between gap-4\"\n     x-data=\"{ orders: [\n       { id: 'PO-24-1187', vendor: 'Gujarat Polymers Ltd', value: '₹18,42,000', due: '28 Aug 2026' },\n       { id: 'PO-24-1191', vendor: 'Nashik Steel Traders',  value: '₹6,04,750',  due: '02 Aug 2026' }\n     ] }\">\n  <template x-for=\"po in orders\" :key=\"po.id\">\n    <span class=\"relative inline-block\"\n          x-data=\"{\n            open: false, timer: 0, up: false, dx: 0,\n            place() {\n              const r = this.$root.getBoundingClientRect(), p = this.$refs.panel, m = 12;\n              this.up = r.bottom + p.offsetHeight > innerHeight - m && r.top - p.offsetHeight > m;\n              this.dx = Math.round(Math.max(m, Math.min(r.left, innerWidth - m - p.offsetWidth)) - r.left);\n            },\n            show(d = 350) {\n              clearTimeout(this.timer);\n              this.timer = setTimeout(() => {\n                this.open = true;\n                this.$nextTick(() => this.place());\n                window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n              }, d);\n            },\n            hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n            shut() { clearTimeout(this.timer); this.open = false; }\n          }\"\n          @mouseenter=\"show()\" @mouseleave=\"hide()\"\n          @focusin=\"show(0)\" @focusout=\"hide()\"\n          @keydown.escape.window=\"shut()\"\n          @hovercard-open.window=\"if ($event.detail !== $root) shut()\"\n          @resize.window.debounce=\"open && place()\">\n\n      <a :href=\"'/orders/' + po.id + '/'\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\" x-text=\"po.id\"></a>\n\n      <div x-ref=\"panel\" x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n           :class=\"up ? 'bottom-full pb-2' : 'top-full pt-2'\"\n           :style=\"'transform: translateX(' + dx + 'px)'\"\n           class=\"absolute left-0 z-40\">\n        <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n          <p class=\"truncate text-[14px]/5 font-semibold tabular-nums\" x-text=\"po.id\"></p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\" x-text=\"po.vendor\"></p>\n          <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n            <div class=\"flex items-baseline justify-between gap-3\">\n              <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n              <dd class=\"text-[12px]/4 font-medium tabular-nums\" x-text=\"po.value\"></dd>\n            </div>\n            <div class=\"flex items-baseline justify-between gap-3\">\n              <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n              <dd class=\"text-[12px]/4 tabular-nums\" x-text=\"po.due\"></dd>\n            </div>\n          </dl>\n          <p class=\"mt-3 border-t border-zinc-100 pt-3 text-[12px]/4 text-zinc-500\">Opens above the trigger near the foot of the page, and shifts left rather than off the edge.</p>\n        </div>\n      </div>\n    </span>\n  </template>\n</div>"
        },
        {
          "id": "htmx",
          "name": "Fetched on first open",
          "html": "<!-- Alpine decides when the card is open; htmx does the fetching, because\n     Alpine does not fetch. The request is a custom event Alpine dispatches at\n     the moment the card actually opens, not on mouseenter — mouseenter fires\n     350ms before there is a card, and a register full of triggers would fetch\n     every row the pointer passed over.\n\n     loaded is what stops the second request. It is set from htmx:afterSwap, so\n     a failed fetch leaves it false and the next open tries again, while a\n     successful one is never fetched a second time for the life of the page.\n     Alpine's .camel modifier is what turns htmx:after-swap in the attribute\n     into the htmx:afterSwap the library really dispatches.\n\n     hx-sync=\"this:drop\" throws away a request raised while one is in flight,\n     which is what a pointer leaving and returning inside 300ms produces. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n  <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Short receipt against</p>\n  <div class=\"mt-1\">\n    <span class=\"relative inline-block\"\n          x-data=\"{\n            open: false, timer: 0, loaded: false, failed: false,\n            show(d = 350) {\n              clearTimeout(this.timer);\n              this.timer = setTimeout(() => {\n                this.open = true;\n                if (!this.loaded) this.$refs.body.dispatchEvent(new CustomEvent('hovercard-fetch'));\n                window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n              }, d);\n            },\n            hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n            shut() { clearTimeout(this.timer); this.open = false; }\n          }\"\n          @mouseenter=\"show()\" @mouseleave=\"hide()\"\n          @focusin=\"show(0)\" @focusout=\"hide()\"\n          @keydown.escape.window=\"shut()\"\n          @hovercard-open.window=\"if ($event.detail !== $root) shut()\"\n          @htmx:after-swap.camel=\"loaded = true; failed = false\"\n          @htmx:response-error.camel=\"failed = true\">\n\n      <a href=\"/orders/1187/\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\">PO-24-1187</a>\n\n      <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n           class=\"absolute top-full left-0 z-40 pt-2\">\n        <div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n\n          <!-- the skeleton is the panel's own content until the response\n               replaces it, and it is the shape of the answer, so the card does\n               not resize under the pointer when the data lands -->\n          <div x-ref=\"body\" x-show=\"!failed\"\n               hx-get=\"/orders/1187/card/\" hx-trigger=\"hovercard-fetch\"\n               hx-swap=\"innerHTML\" hx-sync=\"this:drop\"\n               aria-busy=\"true\">\n            <div class=\"animate-pulse\" aria-hidden=\"true\">\n              <div class=\"h-3 w-28 rounded bg-zinc-200\"></div>\n              <div class=\"mt-2 h-2.5 w-40 rounded bg-zinc-200\"></div>\n              <div class=\"mt-4 space-y-2 border-t border-zinc-100 pt-3\">\n                <div class=\"h-2.5 w-full rounded bg-zinc-200\"></div>\n                <div class=\"h-2.5 w-2/3 rounded bg-zinc-200\"></div>\n              </div>\n            </div>\n          </div>\n\n          <div x-show=\"failed\" x-cloak class=\"flex items-start gap-2.5\">\n            <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n            <div class=\"min-w-0\">\n              <p class=\"text-[13px]/5 font-medium\">Could not load this order</p>\n              <button type=\"button\"\n                      @click=\"failed = false; $refs.body.dispatchEvent(new CustomEvent('hovercard-fetch'))\"\n                      class=\"mt-2 inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[12px]/4 font-medium hover:bg-zinc-100\">\n                <i data-lucide=\"rotate-ccw\" class=\"size-3.5 text-zinc-600\"></i>Try again\n              </button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </span>\n  </div>\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500 tabular-nums\">2 of 3 GRNs posted · last receipt 16 Aug 2026</p>\n</div>"
        },
        {
          "id": "loading",
          "name": "Loading",
          "html": "<!-- The panel's own content before the response lands, standing on its own\n     here so the state can be read at full size. The trigger, the delays and\n     the positioning are in the default and htmx variants; this is the fragment\n     the swap replaces.\n\n     The skeleton is the shape of the card that is coming — a title line, a\n     subtitle, a pill and two figures — because the panel is under the pointer,\n     and one that grows when the data lands moves the thing being read out from\n     under it, or shrinks away from the cursor and closes itself. -->\n<div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\"\n     aria-busy=\"true\" aria-label=\"Loading order PO-24-1187\">\n  <div class=\"animate-pulse\" aria-hidden=\"true\">\n    <div class=\"flex items-start justify-between gap-3\">\n      <div class=\"min-w-0 flex-1\">\n        <div class=\"h-3 w-28 rounded bg-zinc-200\"></div>\n        <div class=\"mt-2 h-2.5 w-40 rounded bg-zinc-200\"></div>\n      </div>\n      <div class=\"h-5 w-20 shrink-0 rounded-full bg-zinc-200\"></div>\n    </div>\n    <div class=\"mt-4 space-y-2 border-t border-zinc-100 pt-3\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"h-2.5 w-16 rounded bg-zinc-200\"></div>\n        <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      </div>\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"h-2.5 w-20 rounded bg-zinc-200\"></div>\n        <div class=\"h-2.5 w-24 rounded bg-zinc-200\"></div>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "error",
          "name": "Failed to load",
          "html": "<!-- The panel when the fetch failed, on its own for the same reason as the\n     skeleton above.\n\n     A hovercard that fails has to say so inside the panel. It cannot fall back\n     to closing itself, because the pointer is still on the trigger and the card\n     would simply reopen. Two ways out, and the second is the important one —\n     the link was always the real route to this record, and it still works when\n     the preview does not. Neutral card, colour only in the icon, exactly as an\n     alert. -->\n<div class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n  <div class=\"flex items-start gap-2.5\">\n    <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-4 shrink-0 text-red-600\"></i>\n    <div class=\"min-w-0\">\n      <p class=\"text-[13px]/5 font-medium tabular-nums\">Could not load PO-24-1187</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">The preview timed out. The order itself is fine.</p>\n    </div>\n  </div>\n  <div class=\"mt-3 flex items-center gap-2 border-t border-zinc-100 pt-3\">\n    <button type=\"button\" class=\"inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[12px]/4 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"rotate-ccw\" class=\"size-3.5 text-zinc-600\"></i>Try again\n    </button>\n    <a href=\"/orders/1187/\" class=\"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">\n      Open the order\n    </a>\n  </div>\n</div>"
        },
        {
          "id": "mobile",
          "name": "On touch",
          "html": "<!-- There is no hover on a touch screen, and a tap fires a synthetic\n     mouseenter, so an unguarded card opens on the tap meant for the link and\n     then has nothing to close it. Read the media query once at init and let it\n     decide which interface the row gets.\n\n     On touch the trigger becomes a real disclosure button, and that is the one\n     place aria-expanded and aria-controls belong: the button's only job is the\n     panel, so expanded and collapsed are true statements about it. The link\n     beside it is untouched and still goes to the order — the card is never the\n     only route to anything in it.\n\n     The panel is in normal flow here rather than floating. At 390px a floating\n     card covers the row it came from; in flow it pushes the page down and\n     nothing is hidden behind it. That is also why the hover target is the whole\n     row rather than the link alone: trigger and panel share one box, so there\n     is no gap for the pointer to fall through and no bridge to build. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white p-4\"\n     x-data=\"{\n       open: false, timer: 0,\n       fine: matchMedia('(hover: hover) and (pointer: fine)').matches,\n       show(d = 350) {\n         clearTimeout(this.timer);\n         this.timer = setTimeout(() => {\n           this.open = true;\n           window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n         }, d);\n       },\n       hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n       shut() { clearTimeout(this.timer); this.open = false; }\n     }\"\n     @mouseenter=\"fine && show()\" @mouseleave=\"fine && hide()\"\n     @focusin=\"fine && show(0)\" @focusout=\"fine && hide()\"\n     @keydown.escape.window=\"shut()\"\n     @click.outside=\"shut()\"\n     @hovercard-open.window=\"if ($event.detail !== $root) shut()\">\n\n  <div class=\"flex items-center justify-between gap-3\">\n    <a href=\"/orders/1187/\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\">PO-24-1187</a>\n    <button type=\"button\" x-show=\"!fine\" x-cloak\n            @click=\"open ? shut() : show(0)\"\n            :aria-expanded=\"open ? 'true' : 'false'\" aria-controls=\"po-1187-preview\"\n            aria-label=\"Preview PO-24-1187\"\n            class=\"flex size-9 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100\">\n      <i data-lucide=\"info\" class=\"size-4\"></i>\n    </button>\n  </div>\n\n  <div id=\"po-1187-preview\" x-show=\"open\" x-cloak x-collapse class=\"mt-3 border-t border-zinc-100 pt-3\">\n    <div class=\"flex items-start justify-between gap-3\">\n      <div class=\"min-w-0\">\n        <p class=\"truncate text-[13px]/5 font-medium\">Gujarat Polymers Ltd</p>\n        <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600 tabular-nums\">Raised 12 Aug 2026 · V-0142</p>\n      </div>\n      <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n      </span>\n    </div>\n    <dl class=\"mt-3 space-y-1.5\">\n      <div class=\"flex items-baseline justify-between gap-3\">\n        <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n        <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹18,42,000</dd>\n      </div>\n      <div class=\"flex items-baseline justify-between gap-3\">\n        <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n        <dd class=\"text-[12px]/4 tabular-nums\">28 Aug 2026</dd>\n      </div>\n    </dl>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django register row",
          "html": "<!-- One row of a register, and the partial the card is fetched from. The same\n     template renders the panel for htmx and for a direct request, so the\n     preview and the page cannot drift apart.\n\n     # views.py\n     def order_card(request, pk):\n         order = get_object_or_404(\n             Order.objects.select_related('vendor', 'raised_by'), pk=pk\n         )\n         return render(request, 'orders/_hovercard.html', {'order': order})\n\n     # urls.py\n     path('orders/<int:pk>/card/', order_card, name='order-card'),\n\n     select_related is not a tidy-up here. The card names the vendor and the\n     person who raised the order, so without it every hovered row costs three\n     queries, and a user running the pointer down a page of fifty makes a\n     hundred and fifty.\n\n     Nothing in the block carries an id, which is what makes it safe inside a\n     for loop. The moment a hovercard names its panel — aria-controls, or an\n     htmx hx-target — the id needs {{ order.pk }} in it on every row, and\n     duplicate ids are the failure that follows a copy-paste into a loop.\n\n     Give the view the same cache headers as any other read-only partial. A\n     hovercard is the one component a user can fire fifty times in ten seconds\n     without meaning to. -->\n{% for order in page_obj %}\n  <tr class=\"border-b border-zinc-100\">\n    <td class=\"px-4 py-2.5\">\n      <span class=\"relative inline-block\"\n            x-data=\"{\n              open: false, timer: 0, loaded: false,\n              show(d = 350) {\n                clearTimeout(this.timer);\n                this.timer = setTimeout(() => {\n                  this.open = true;\n                  if (!this.loaded) this.$refs.body.dispatchEvent(new CustomEvent('hovercard-fetch'));\n                  window.dispatchEvent(new CustomEvent('hovercard-open', { detail: this.$root }));\n                }, d);\n              },\n              hide() { clearTimeout(this.timer); this.timer = setTimeout(() => this.open = false, 200); },\n              shut() { clearTimeout(this.timer); this.open = false; }\n            }\"\n            @mouseenter=\"show()\" @mouseleave=\"hide()\"\n            @focusin=\"show(0)\" @focusout=\"hide()\"\n            @keydown.escape.window=\"shut()\"\n            @hovercard-open.window=\"if ($event.detail !== $root) shut()\"\n            @htmx:after-swap.camel=\"loaded = true\">\n\n        <a href=\"{% url 'order-detail' order.pk %}\" class=\"font-medium text-zinc-900 underline underline-offset-2 tabular-nums\">{{ order.number }}</a>\n\n        <div x-show=\"open\" x-cloak x-transition.opacity.duration.150ms\n             class=\"absolute top-full left-0 z-40 pt-2\">\n          <div x-ref=\"body\"\n               hx-get=\"{% url 'order-card' order.pk %}\" hx-trigger=\"hovercard-fetch\"\n               hx-swap=\"innerHTML\" hx-sync=\"this:drop\"\n               class=\"w-80 max-w-[calc(100vw_-_1.5rem)] rounded-xl border border-zinc-200 bg-white p-4 shadow-lg\">\n            <div class=\"animate-pulse\" aria-hidden=\"true\">\n              <div class=\"h-3 w-28 rounded bg-zinc-200\"></div>\n              <div class=\"mt-2 h-2.5 w-40 rounded bg-zinc-200\"></div>\n            </div>\n          </div>\n        </div>\n      </span>\n    </td>\n    <td class=\"hidden px-4 py-2.5 text-zinc-600 sm:table-cell\">{{ order.vendor.name }}</td>\n    <td class=\"px-4 py-2.5 text-right tabular-nums\">₹{{ order.value|intcomma }}</td>\n  </tr>\n{% endfor %}\n\n{# orders/_hovercard.html — swapped into the panel, and rendered on its own\n   when someone opens the URL directly. status_dot is the filter the badge\n   component defines; it is the single place the status colour is decided. #}\n{% load humanize ui %}\n<div class=\"flex items-start justify-between gap-3\">\n  <div class=\"min-w-0\">\n    <p class=\"truncate text-[14px]/5 font-semibold tabular-nums\">{{ order.number }}</p>\n    <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">{{ order.vendor.name }}</p>\n  </div>\n  <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n    <span class=\"size-1.5 rounded-full {{ order.status|status_dot }}\" aria-hidden=\"true\"></span>{{ order.get_status_display }}\n  </span>\n</div>\n<dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n    <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹{{ order.value|intcomma }}</dd>\n  </div>\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n    <dd class=\"text-[12px]/4 tabular-nums\">{{ order.delivery_date|date:\"d M Y\" }}</dd>\n  </div>\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <dt class=\"text-[12px]/4 text-zinc-600\">Received</dt>\n    <dd class=\"text-[12px]/4 tabular-nums\">{{ order.received_count }} of {{ order.line_count }} GRNs</dd>\n  </div>\n</dl>\n<p class=\"mt-3 border-t border-zinc-100 pt-3 text-[12px]/4 text-zinc-500 tabular-nums\">Raised by {{ order.raised_by.get_full_name }} · {{ order.raised_on|date:\"d M Y\" }}</p>"
        }
      ]
    },
    {
      "id": "avatar",
      "name": "Avatar",
      "category": "feedback",
      "description": "Initials in a circle standing in for a person. There are no photographs in this system, so a zinc tint and two letters is the whole thing.",
      "when_to_use": "Naming who raised, approved, or is assigned to a record. Always pair it with the name unless the row genuinely has no width for one — an avatar alone identifies nobody.",
      "rules": [
        "Two initials, never one. Two people named Sanjay collide immediately, and a single letter is not a name.",
        "The circle is aria-hidden wherever the name is written beside it, or a screen reader reads \"RD Ritu Deshpande\" and the initials become noise.",
        "Where the circle stands alone, it needs the full name through aria-label or a title — the letters are meaningless to anyone who does not already know the person.",
        "A stack needs the names on the group, not on the pieces. The +4 chip on its own tells nobody who is in the group.",
        "Never colour avatars by hashing the name. It looks lively and it means nothing, and in this system colour is reserved for record state.",
        "The graphite fill marks the signed-in user and only the signed-in user. If every avatar is graphite the distinction is gone.",
        "shrink-0 on the circle, min-w-0 on the text beside it. Without both, a long name squashes the circle into an ellipse.",
        "Initials come from the server, already computed. Slicing a name in the template gets Indian and single-word names wrong.",
        "The tinted circle is bg-zinc-200, not bg-zinc-100. zinc-100 is the page background, so a zinc-100 avatar has no fill at all wherever it sits on the page, on a selected row, or in a preview panel — the ring alone is left and the initials float inside an outline.",
        "Initials are text and have to clear 4.5:1. zinc-500 on zinc-100 measures 4.39 and fails; zinc-600 on zinc-200 measures 6.08 and passes.",
        "truncate on a name inside a table cell does nothing unless the table is table-fixed. Under the default auto layout the cell grows to fit the name — measured here at 318px stretching to 483px — and pushes the columns to its right off the edge."
      ],
      "anatomy": [
        {
          "part": "Circle",
          "description": "A rounded-full box at size-7, size-9 or size-11. shrink-0 always, or a long name beside it deforms the circle."
        },
        {
          "part": "Initials",
          "description": "Two letters, font-medium, one step down from the text they sit beside so they do not shout."
        },
        {
          "part": "Fill",
          "description": "bg-zinc-200 with zinc-600 letters for everyone; bg-zinc-700 with white letters for the signed-in user alone."
        },
        {
          "part": "Edge",
          "description": "ring-1 ring-inset ring-zinc-300 on the tinted fill. The graphite fill needs none."
        },
        {
          "part": "Ring",
          "description": "ring-2 ring-white, used only in a stack, so the overlap reads as separate circles rather than one shape."
        },
        {
          "part": "Overflow chip",
          "description": "The +N at the end of a stack. tabular-nums, and the group carries the names it stands for."
        },
        {
          "part": "Presence dot",
          "description": "Optional, bottom-right, on a white ring. It says something about the person, never about the record."
        }
      ],
      "behaviour": [
        "The circle is a fixed square at every size, so a row of avatars keeps its rhythm regardless of name length.",
        "Text beside an avatar truncates; the circle never shrinks. That is shrink-0 on the circle and min-w-0 on the text.",
        "A stack overlaps by -space-x-5 and each circle carries a 2px white ring, which is what separates them where they cover each other",
        "The stack shows three or four and rolls the rest into a +N chip. Past that the row stops being scannable.",
        "Initials are supplied by the server. Deriving them in the template mishandles single-word names, three-part names and names where the family name comes first.",
        "Size follows context: size-7 in a table row, size-9 in a list or a card header, size-11 in a record header."
      ],
      "accessibility": [
        "Where the name is written beside the avatar, the circle is aria-hidden — the name is already there and the letters would be read twice.",
        "Where the avatar stands alone, it carries the full name through aria-label, because two letters identify nobody.",
        "A stack puts the names on the group as one accessible label, rather than leaving four unlabelled circles and a number.",
        "The +N chip is included in that group label — \"and 4 more\" — so the count is not an orphaned digit.",
        "Colour carries no information here, so nothing is lost by not seeing it. The graphite fill is a convenience, not a signal.",
        "An avatar that opens a menu is a real button with its own accessible name; the circle itself is never the only clickable thing."
      ],
      "related": [
        "badge",
        "tooltip",
        "topbar"
      ],
      "variants": [
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- size-7 in a table row, size-9 in a list or card header, size-11 on a record.\n     The text step drops with the circle so the letters never crowd the edge. -->\n<div class=\"flex flex-wrap items-center gap-4\">\n  <span class=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[11px]/4 font-medium text-zinc-600\" aria-label=\"Ritu Deshpande\" role=\"img\">RD</span>\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-label=\"Ritu Deshpande\" role=\"img\">RD</span>\n  <span class=\"flex size-11 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[14px]/5 font-medium text-zinc-600\" aria-label=\"Ritu Deshpande\" role=\"img\">RD</span>\n</div>"
        },
        {
          "id": "self",
          "name": "The signed-in user",
          "html": "<!-- Graphite marks you, and nobody else. If every avatar is filled, the\n     distinction it exists to make has gone. -->\n<div class=\"flex flex-wrap items-center gap-4\">\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-700 text-[13px]/5 font-medium text-white\" aria-label=\"Ritu Deshpande, you\" role=\"img\">RD</span>\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-label=\"Sanjay More\" role=\"img\">SM</span>\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-label=\"Imran Qureshi\" role=\"img\">IQ</span>\n</div>"
        },
        {
          "id": "with-name",
          "name": "With name and role",
          "html": "<!-- The name is written, so the circle is aria-hidden. Otherwise a screen\n     reader announces \"RD Ritu Deshpande\". -->\n<div class=\"flex items-center gap-3\">\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-hidden=\"true\">RD</span>\n  <div class=\"min-w-0\">\n    <p class=\"truncate text-[13px]/5 font-medium\">Ritu Deshpande</p>\n    <p class=\"truncate text-[12px]/4 text-zinc-600\">Purchase lead · approved PO-24-1187</p>\n  </div>\n</div>\n\n<div class=\"mt-3 flex items-center gap-3\">\n  <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-hidden=\"true\">SM</span>\n  <div class=\"min-w-0 flex-1\">\n    <p class=\"truncate text-[13px]/5 font-medium\">Sanjay More</p>\n    <p class=\"truncate text-[12px]/4 text-zinc-600\">Stores, Plant 2 · posted GRN 1142</p>\n  </div>\n  <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">16 Aug</span>\n</div>"
        },
        {
          "id": "stacked",
          "name": "Stacked group",
          "html": "<!-- Circles sit over the ones beneath them and cover most of their initials.\n     That is the point: a stack answers \"how many and roughly who\", not \"which\n     one is Sanjay\". Keeping every pair of letters readable would mean barely\n     overlapping at all, and then it reads as a row rather than a group.\n\n     The names are on the group, which is what a screen reader gets. The +N\n     chip is last and therefore fully visible, because it is the one part\n     that has to be read. -->\n<div class=\"flex items-center -space-x-5\" role=\"img\"\n     aria-label=\"Approvers: Ritu Deshpande, Sanjay More, Imran Qureshi and 3 more\">\n  <span class=\"flex size-11 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-2 ring-white\" aria-hidden=\"true\">RD</span>\n  <span class=\"flex size-11 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-2 ring-white\" aria-hidden=\"true\">SM</span>\n  <span class=\"flex size-11 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-2 ring-white\" aria-hidden=\"true\">IQ</span>\n  <span class=\"flex size-11 items-center justify-center rounded-full bg-zinc-700 text-[11px]/4 font-medium tabular-nums text-white ring-2 ring-white\" aria-hidden=\"true\">+3</span>\n</div>"
        },
        {
          "id": "in-row",
          "name": "In a table cell",
          "html": "<!-- table-fixed is what makes truncate work. Under the default auto layout a\n     cell grows to fit its content, so a long name widens the column and pushes\n     the amount off the right edge instead of ellipsing. Widths are declared on\n     the first row. -->\n<table class=\"w-full table-fixed\">\n  <tbody class=\"divide-y divide-zinc-100\">\n    <tr>\n      <td class=\"w-[7.5rem] py-2.5 pr-4 text-[13px]/5 font-medium tabular-nums\">PO-24-1187</td>\n      <td class=\"py-2.5 pr-4\">\n        <div class=\"flex items-center gap-2\">\n          <span class=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[11px]/4 font-medium text-zinc-600\" aria-hidden=\"true\">RD</span>\n          <span class=\"min-w-0 truncate text-[13px]/5\">Ritu Deshpande</span>\n        </div>\n      </td>\n      <td class=\"w-[7.5rem] py-2.5 text-right text-[13px]/5 tabular-nums\">₹18,42,000</td>\n    </tr>\n    <tr>\n      <td class=\"py-2.5 pr-4 text-[13px]/5 font-medium tabular-nums\">PO-24-1163</td>\n      <td class=\"py-2.5 pr-4\">\n        <div class=\"flex items-center gap-2\">\n          <span class=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[11px]/4 font-medium text-zinc-600\" aria-hidden=\"true\">SM</span>\n          <span class=\"min-w-0 truncate text-[13px]/5\">Venkataraman Balasubramanian Krishnamurthy</span>\n        </div>\n      </td>\n      <td class=\"py-2.5 text-right text-[13px]/5 tabular-nums\">₹4,26,500</td>\n    </tr>\n  </tbody>\n</table>"
        },
        {
          "id": "presence",
          "name": "With presence",
          "html": "<!-- The dot says something about the person. It never carries record state —\n     that belongs on a badge, where the colour mapping is fixed. -->\n<div class=\"flex flex-wrap items-center gap-6\">\n  <div class=\"flex items-center gap-3\">\n    <span class=\"relative shrink-0\">\n      <span class=\"flex size-9 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-hidden=\"true\">RD</span>\n      <span class=\"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-emerald-600 ring-2 ring-white\"></span>\n    </span>\n    <div class=\"min-w-0\">\n      <p class=\"truncate text-[13px]/5 font-medium\">Ritu Deshpande</p>\n      <p class=\"truncate text-[12px]/4 text-zinc-600\">Online</p>\n    </div>\n  </div>\n  <div class=\"flex items-center gap-3\">\n    <span class=\"relative shrink-0\">\n      <span class=\"flex size-9 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[13px]/5 font-medium text-zinc-600\" aria-hidden=\"true\">SM</span>\n      <span class=\"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-zinc-300 ring-2 ring-white\"></span>\n    </span>\n    <div class=\"min-w-0\">\n      <p class=\"truncate text-[13px]/5 font-medium\">Sanjay More</p>\n      <p class=\"truncate text-[12px]/4 text-zinc-600\">Last seen 16 Aug</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "menu",
          "name": "Account trigger",
          "html": "<!-- The circle is inside a real button carrying its own name. An avatar that\n     is the only clickable thing gives the keyboard nothing to land on. -->\n<div class=\"flex justify-end\">\n  <div class=\"relative inline-block\" x-data=\"{ open: false }\" @click.outside=\"open = false\">\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-haspopup=\"menu\"\n          class=\"flex items-center gap-2 rounded-lg py-1 pl-1 pr-2 hover:bg-zinc-100\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-zinc-700 text-[12px]/4 font-medium text-white\" aria-hidden=\"true\">RD</span>\n    <span class=\"hidden text-[13px]/5 font-medium sm:block\">Ritu Deshpande</span>\n    <i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-600\"></i>\n  </button>\n\n  <div x-show=\"open\" x-cloak role=\"menu\"\n       class=\"absolute right-0 z-40 mt-1 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n    <div class=\"border-b border-zinc-200 px-3 py-2\">\n      <p class=\"truncate text-[13px]/5 font-medium\">Ritu Deshpande</p>\n      <p class=\"truncate text-[12px]/4 text-zinc-600\">ritu.deshpande@konspec.com</p>\n    </div>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"user\" class=\"size-4 text-zinc-600\"></i>Profile\n    </button>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"settings\" class=\"size-4 text-zinc-600\"></i>Preferences\n    </button>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 border-t border-zinc-200 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"log-out\" class=\"size-4 text-zinc-600\"></i>Sign out\n    </button>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "placeholder",
          "name": "Unassigned",
          "html": "<!-- Nobody is a real state and needs a real rendering. An empty circle reads\n     as a loading bug; a dashed one with a verb reads as an invitation. -->\n<div class=\"flex flex-wrap items-center gap-6\">\n  <div class=\"flex items-center gap-3\">\n    <span class=\"flex size-9 shrink-0 items-center justify-center rounded-full border border-dashed border-zinc-300 text-zinc-500\" aria-hidden=\"true\">\n      <i data-lucide=\"user\" class=\"size-4\"></i>\n    </span>\n    <div class=\"min-w-0\">\n      <p class=\"truncate text-[13px]/5 text-zinc-600\">Unassigned</p>\n      <p class=\"truncate text-[12px]/4 text-zinc-500\">PO-24-1191 · raised 18 Aug</p>\n    </div>\n  </div>\n  <button type=\"button\" class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"user-plus\" class=\"size-4 text-zinc-600\"></i>Assign approver\n  </button>\n</div>"
        }
      ]
    },
    {
      "id": "field",
      "name": "Field",
      "category": "forms",
      "description": "Label, control, help text and error in one block. This is the unit every form is built from.",
      "when_to_use": "Every form input. Do not place a bare <input> in a form without this wrapper.",
      "rules": [
        "The error message replaces the help text, it does not stack under it.",
        "Mark required fields with a red asterisk in the label, and say so once at the top of the form.",
        "In Django, the error paragraph maps to {{ field.errors }} and the help paragraph to {{ field.help_text }}."
      ],
      "anatomy": [
        {
          "part": "Label",
          "description": "Bound to the control with for/id. Carries the required asterisk when there is one."
        },
        {
          "part": "Control",
          "description": "The input, select or textarea. Never bare — it always sits inside this wrapper."
        },
        {
          "part": "Help text",
          "description": "12px zinc-400 below the control, explaining the format or the constraint before it is broken."
        },
        {
          "part": "Error",
          "description": "12px red-600, replacing the help text rather than stacking under it."
        },
        {
          "part": "Required marker",
          "description": "A red asterisk in the label, explained once at the top of the form rather than beside every field."
        }
      ],
      "behaviour": [
        "The error replaces the help text. Stacking both makes the block grow and pushes the rest of the form down as the user types.",
        "An error appears after the field is left, not while it is being typed into — validating mid-keystroke tells someone their half-typed entry is wrong.",
        "Once a field has errored, it revalidates as the user corrects it, so the message clears as soon as it is true.",
        "The field keeps its height between valid and invalid states wherever possible, so a form does not jump on submit.",
        "In Django this maps directly: the error paragraph is {{ field.errors }} and the help paragraph is {{ field.help_text }}."
      ],
      "accessibility": [
        "The label is bound with for/id — a label that merely sits above the control is not connected to it.",
        "The control carries aria-describedby pointing at the help text, and at the error when there is one.",
        "An invalid control sets aria-invalid=\"true\", so the state is announced and not only drawn in red.",
        "The required marker is backed by the required attribute; a red asterisk alone is decoration.",
        "The error text is real text under the field, never a title attribute or a tooltip."
      ],
      "related": [
        "input",
        "textarea",
        "form-page"
      ],
      "variants": [
        {
          "id": "default",
          "name": "With help text",
          "html": "<div>\n  <label for=\"title\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Order title <span class=\"text-red-600\">*</span></label>\n  <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <input id=\"title\" value=\"MS angles and plates — August lot\"\n           class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Shown on the printed order and in vendor emails.</p>\n</div>"
        },
        {
          "id": "error",
          "name": "With error",
          "html": "<div>\n  <label for=\"vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor <span class=\"text-red-600\">*</span></label>\n  <div class=\"rounded-lg border border-red-600 bg-white focus-within:ring-3 focus-within:ring-red-600/15\">\n    <select id=\"vendor\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n      <option>Gujarat Polymers Ltd</option>\n    </select>\n  </div>\n  <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4 font-medium text-red-600\">\n    <i data-lucide=\"alert-circle\" class=\"size-3.5\"></i>No active rate contract for this vendor.\n  </p>\n</div>"
        }
      ]
    },
    {
      "id": "input",
      "name": "Input",
      "category": "forms",
      "description": "Single-line text entry. The border lives on the wrapper so icons and prefixes sit inside the focus ring.",
      "when_to_use": "Text, numbers, dates, search.",
      "rules": [
        "Never put the focus ring on the <input> itself — put it on the wrapper with focus-within.",
        "Never leave a read-only field white, bordered and ringed. It is then pixel for pixel an editable one, and the only way to find out otherwise is to click into it and get nothing back. Read-only takes the same bg-zinc-100 and dropped ring that disabled takes; the text contrast is what tells them apart."
      ],
      "anatomy": [
        {
          "part": "Wrapper",
          "description": "The bordered box. This is what owns the focus ring, so icons and prefixes sit inside it."
        },
        {
          "part": "Control",
          "description": "A borderless, transparent input with outline-none — all the visible styling belongs to the wrapper."
        },
        {
          "part": "Icon",
          "description": "Optional, left of the text, size-4 zinc-400. Decorative."
        },
        {
          "part": "Prefix or suffix",
          "description": "A fixed unit such as ₹ or kg, sitting inside the ring so it reads as part of the value."
        },
        {
          "part": "Focus ring",
          "description": "focus-within on the wrapper: border zinc-700 plus a 3px zinc-700/15 halo."
        }
      ],
      "behaviour": [
        "The focus ring goes on the wrapper via focus-within, never on the input itself, or icons and units end up outside the ring.",
        "Numeric inputs are right-aligned with tabular-nums, so a column of them lines up.",
        "Read-only and disabled share one locked surface, bg-zinc-100 with no focus ring, because neither can be typed into. Only the text separates them: zinc-900 for read-only, whose value still matters and still has to be copyable, zinc-400 for disabled, whose value does not.",
        "Placeholder text is an example of the format, never a replacement for the label.",
        "The control fills the wrapper's width, so the whole box is a click target and not just the text."
      ],
      "accessibility": [
        "Every input has a real label; a placeholder is not one and disappears as soon as typing starts.",
        "Disabled uses the disabled attribute so the control leaves the Tab order; read-only uses readonly so it stays reachable and its value can be copied.",
        "The focus ring is visible against both white and zinc-100 backgrounds.",
        "Units in a prefix or suffix are part of the field's description, so the value is not announced without them.",
        "type is set correctly — email, date, number — so the right keyboard appears on a phone."
      ],
      "related": [
        "field",
        "textarea",
        "combobox"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n  <input placeholder=\"Placeholder\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n</div>"
        },
        {
          "id": "icon",
          "name": "With icon and prefix",
          "html": "<div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n  <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n  <input placeholder=\"Search orders\" class=\"w-full bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n</div>\n\n<div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n  <span class=\"pl-3 text-[14px]/5 text-zinc-600\">₹</span>\n  <input value=\"18,42,000\" class=\"w-full bg-transparent px-2 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n</div>"
        },
        {
          "id": "disabled",
          "name": "Disabled and read-only",
          "html": "<div class=\"max-w-xs space-y-5\">\n  <div>\n    <label for=\"in-off\" class=\"mb-1.5 block text-[13px]/5 font-medium text-zinc-500\">Rate contract</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-zinc-100\">\n      <input id=\"in-off\" disabled value=\"Locked by policy\"\n             class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 text-zinc-400\">\n    </div>\n  </div>\n\n  <div>\n    <!-- One locked surface for both, and the text is what separates them. A\n         read-only value left on white with a focus ring is indistinguishable\n         from an editable field until someone clicks into it and nothing\n         happens; filled and ringless, it reads as closed at a glance and still\n         reads at full contrast, because the value still matters. -->\n    <label for=\"in-ro\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Order number</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-zinc-100\">\n      <input id=\"in-ro\" readonly value=\"PO-24-1187\"\n             class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "textarea",
      "name": "Textarea",
      "category": "forms",
      "description": "Multi-line text entry. Same bordered wrapper as the input, a height measured in rows, and a counter when there is a limit worth showing.",
      "when_to_use": "Free text longer than a line — notes, remarks, an address, a reason for a revision.",
      "rules": [
        "Give the control block. A textarea is inline-block by default, so it sits on a text baseline and leaves a 5px strip of wrapper below it that looks like a rendering bug.",
        "Set the height with rows, never with an h- class. h-[100px] against a 20px leading is 4.2 lines, and the fifth line is sliced in half along its x-height.",
        "Preflight already sets resize: vertical, so resize-y is redundant and resize-x is a layout bug waiting to happen. The only resize class worth writing is resize-none, on a box whose height is owned by script.",
        "Never leave a read-only box white, bordered and ringed. It is then pixel for pixel an editable field, and the only way to find out otherwise is to click into it and get nothing back. Read-only takes bg-zinc-100 and drops the focus ring, the same locked surface disabled uses.",
        "Enter inserts a newline. Never bind Enter to submit — the one key someone needs to write a second line must not post the form.",
        "maxlength truncates a paste in silence. Use it only when the limit is the column width, and say the number in the help text before it is reached; otherwise count past the limit and block the submit, so the user can see what has to go.",
        "Set the height back to auto before reading scrollHeight, or an auto-growing box grows and never shrinks — scrollHeight cannot report less than the height already set.",
        "Re-measure an auto-growing box on resize. Its height was computed at whatever width it had when it was first painted, and that is the wrong height at 390px.",
        "The counter is tabular-nums. Proportional digits change width as they count and the label beside them shifts on every keystroke."
      ],
      "anatomy": [
        {
          "part": "Wrapper",
          "description": "The bordered box, and what owns the focus ring. Same as the input, which is why a footer row can sit inside the ring."
        },
        {
          "part": "Control",
          "description": "A borderless, transparent, block-level textarea with outline-none. Its height comes from rows."
        },
        {
          "part": "Label row",
          "description": "The label on the left, the counter on the right, on one line above the box, so the counter costs no vertical space."
        },
        {
          "part": "Counter",
          "description": "11px mono tabular-nums, counting down. zinc-500, amber-700 inside the last 20, red-600 once it is over."
        },
        {
          "part": "Footer",
          "description": "An optional row inside the ring: a hint on the left, the submit on the right. This is what the wrapper border buys."
        },
        {
          "part": "Help or error",
          "description": "12px under the box. The error replaces the help text rather than stacking under it, exactly as in Field."
        }
      ],
      "behaviour": [
        "The height is a number of rows, so the box is always a whole number of lines and nothing is ever half-visible at the bottom.",
        "Enter inserts a newline and never submits. Where a submit shortcut is genuinely wanted, it is Ctrl or Cmd plus Enter, and it is written in the hint rather than left to be discovered.",
        "The counter counts down, not up: what is left is the number the writer is deciding against. It turns amber inside the last 20 characters and red once it is over, and the submit disables while it is over.",
        "An auto-growing box grows with its content up to a ceiling, then stops and scrolls. Without the ceiling a long paste pushes the submit button off the screen.",
        "Resize is vertical only, so a textarea can never be dragged wider than the form it sits in. An auto-growing box drops the handle entirely, because script and the drag would fight over the same height.",
        "Read-only and disabled share one locked surface, bg-zinc-100 with no focus ring, because both are boxes you cannot type into. The text is what separates them: zinc-900 for read-only, whose value still matters and still has to be copyable, zinc-400 for disabled, whose value does not. A read-only field left white, bordered and ringed says nothing at all until someone clicks into it and nothing happens."
      ],
      "accessibility": [
        "A real label bound with for/id. A placeholder is not a label, and in a box this size it disappears the moment anyone starts typing.",
        "aria-describedby points at the help text, at the error when there is one, and at the counter, so the limit is announced with the field and not left as a number floating beside it.",
        "The visible counter is aria-hidden and mirrored in a polite live region that stays empty until the last 20 characters, then updates on a debounce. Announcing a count on every keystroke makes the field unusable with a screen reader.",
        "Over the limit sets aria-invalid on the control, and the reason is real text under the box, not a colour and not a title attribute.",
        "Never a contenteditable div. A real textarea brings keyboard support, IME composition, spellcheck, undo and form submission with it, and none of that is worth reimplementing.",
        "The focus ring sits on the wrapper via focus-within, so it stays visible against both white and zinc-100 and never leaves the footer row outside it."
      ],
      "related": [
        "field",
        "input",
        "form-page"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"max-w-xl\">\n  <label for=\"ta-notes\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Delivery instructions</label>\n  <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <textarea id=\"ta-notes\" name=\"notes\" rows=\"4\"\n              placeholder=\"Gate timings, unloading contact, anything the driver needs to know\"\n              aria-describedby=\"ta-notes-help\"\n              class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\"></textarea>\n  </div>\n  <p id=\"ta-notes-help\" class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Printed on the delivery challan.</p>\n</div>"
        },
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- Three heights, and rows is what sets all three. A pixel height cuts the\n     last line in half: h-[100px] against a 20px leading is 4.2 lines. -->\n<div class=\"max-w-xl space-y-5\">\n  <div>\n    <label for=\"ta-2\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Two rows — a remark inside a table row or a dialog</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n      <textarea id=\"ta-2\" rows=\"2\" class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">Short shipped by 40 kg, balance promised Friday.</textarea>\n    </div>\n  </div>\n\n  <div>\n    <label for=\"ta-4\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Four rows — the default for a form field</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n      <textarea id=\"ta-4\" rows=\"4\" class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">Konspec Industries\nPlot 214, Silvassa Industrial Estate\nDadra &amp; Nagar Haveli 396230</textarea>\n    </div>\n  </div>\n\n  <div>\n    <label for=\"ta-10\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Ten rows — the page is the field</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n      <textarea id=\"ta-10\" rows=\"10\" class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\" placeholder=\"Scope of work\"></textarea>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "counter",
          "name": "With a counter",
          "html": "<!-- A soft limit: typing past it is allowed, submitting past it is not. A hard\n     maxlength would swallow the tail of a paste without saying so. -->\n<div class=\"max-w-xl\"\n     x-data=\"{\n       text: 'Rate revised after the vendor withdrew the August discount.',\n       limit: 180,\n       t: null,\n       announce: '',\n       get left() { return this.limit - this.text.length; },\n       get over() { return this.left < 0; },\n       get msg() { return this.over ? Math.abs(this.left) + ' over the limit' : this.left + ' left'; },\n       get tone() { return this.over ? 'text-red-600' : this.left <= 20 ? 'text-amber-700' : 'text-zinc-500'; },\n       say(m) { clearTimeout(this.t); this.t = setTimeout(() => this.announce = m, 700); }\n     }\"\n     x-effect=\"say(left <= 20 ? msg : '')\">\n  <div class=\"mb-1.5 flex items-baseline justify-between gap-3\">\n    <label for=\"ta-reason\" class=\"text-[13px]/5 font-medium\">Reason for revision <span class=\"text-red-600\">*</span></label>\n    <span id=\"ta-reason-count\" aria-hidden=\"true\"\n          class=\"shrink-0 font-mono text-[11px]/4 tabular-nums\" :class=\"tone\" x-text=\"msg\"></span>\n  </div>\n\n  <div class=\"rounded-lg bg-white border\"\n       :class=\"over ? 'border-red-600 focus-within:ring-3 focus-within:ring-red-600/15'\n                    : 'border-zinc-200 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15'\">\n    <textarea id=\"ta-reason\" name=\"reason\" rows=\"4\" x-model=\"text\"\n              :aria-invalid=\"over ? 'true' : null\"\n              aria-describedby=\"ta-reason-help ta-reason-count\"\n              class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\"></textarea>\n  </div>\n\n  <p id=\"ta-reason-help\" class=\"mt-1.5 text-[12px]/4\" :class=\"over ? 'font-medium text-red-600' : 'text-zinc-500'\">\n    <span x-show=\"!over\">Goes on the amendment record, visible to the vendor. 180 characters.</span>\n    <span x-show=\"over\" x-cloak class=\"flex items-center gap-1.5\">\n      <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>Too long to fit on the amendment record.\n    </span>\n  </p>\n\n  <!-- silent until it matters, then debounced, so a screen reader is not read a\n       running count on every keystroke -->\n  <span class=\"sr-only\" aria-live=\"polite\" x-text=\"announce\"></span>\n</div>"
        },
        {
          "id": "autogrow",
          "name": "Auto-growing",
          "html": "<!-- Grows with the content up to a ceiling, then scrolls. Chromium and Safari\n     can do this in one class, field-sizing-content, but Firefox cannot yet, so\n     this is the version that ships.\n\n     Two things break it: reading scrollHeight without resetting the height to\n     auto first (it can never report less than the height already set, so the\n     box only ever grows), and never re-measuring, which leaves a box sized at\n     desktop width still that tall at 390px. -->\n<div class=\"max-w-xl\"\n     x-data=\"{\n       grow() {\n         const t = this.$refs.ta;\n         t.style.height = 'auto';\n         t.style.height = t.scrollHeight + 'px';\n       }\n     }\"\n     x-init=\"$nextTick(() => grow())\"\n     @resize.window.debounce=\"grow()\">\n  <label for=\"ta-grow\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Inspection remarks</label>\n  <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <textarea id=\"ta-grow\" name=\"remarks\" x-ref=\"ta\" rows=\"2\" @input=\"grow()\"\n              placeholder=\"Type — the box follows\"\n              class=\"block max-h-54 w-full resize-none overflow-y-auto bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">Material received against GRN-24-0912.\nTwo bundles show mill scale on the outer face.\nHeld pending the test certificate.</textarea>\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Stops growing at 10 rows and scrolls after that.</p>\n</div>"
        },
        {
          "id": "toolbar",
          "name": "With a footer",
          "html": "<!-- The footer sits inside the ring, which is the whole reason the border is on\n     the wrapper and not on the control. Ctrl or Cmd plus Enter posts; a bare\n     Enter writes a newline, because that is what the key is for. -->\n<div class=\"max-w-xl\" x-data=\"{ text: '' }\">\n  <div class=\"rounded-xl border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <label for=\"ta-note\" class=\"sr-only\">Add a note to this order</label>\n    <textarea id=\"ta-note\" name=\"body\" rows=\"3\" x-model=\"text\"\n              @keydown.ctrl.enter=\"if (text.trim()) $refs.post.click()\"\n              @keydown.meta.enter=\"if (text.trim()) $refs.post.click()\"\n              placeholder=\"Add a note for whoever picks this up next\"\n              class=\"block w-full resize-none bg-transparent px-3.5 pt-3 text-[14px]/5 outline-none placeholder:text-zinc-500\"></textarea>\n\n    <div class=\"flex items-center justify-between gap-3 px-3.5 pb-3 pt-1.5\">\n      <span class=\"min-w-0 text-[12px]/4 text-zinc-500\">\n        Visible to everyone on this order\n        <!-- a phone has no Ctrl key, so the hint goes rather than truncates -->\n        <span class=\"hidden sm:inline\">\n          <kbd class=\"ml-1 rounded border border-zinc-200 bg-zinc-100 px-1 py-0.5 font-mono text-[10px]/3 text-zinc-600\">Ctrl</kbd>\n          <kbd class=\"rounded border border-zinc-200 bg-zinc-100 px-1 py-0.5 font-mono text-[10px]/3 text-zinc-600\">Enter</kbd>\n        </span>\n      </span>\n      <button type=\"submit\" x-ref=\"post\" :disabled=\"!text.trim()\"\n              class=\"inline-flex h-8 shrink-0 items-center rounded-lg border border-transparent bg-zinc-700 px-3 text-[13px]/5 font-medium text-white hover:bg-zinc-800 disabled:bg-zinc-200 disabled:text-zinc-400\">\n        Post note\n      </button>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "error",
          "name": "With error",
          "html": "<div class=\"max-w-xl\">\n  <label for=\"ta-bad\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Rejection reason <span class=\"text-red-600\">*</span></label>\n  <div class=\"rounded-lg border border-red-600 bg-white focus-within:ring-3 focus-within:ring-red-600/15\">\n    <textarea id=\"ta-bad\" name=\"reason\" rows=\"4\" aria-invalid=\"true\" aria-describedby=\"ta-bad-err\"\n              class=\"block w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\"></textarea>\n  </div>\n  <!-- the error replaces the help text, it does not stack under it -->\n  <p id=\"ta-bad-err\" class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4 font-medium text-red-600\">\n    <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>A reason is required before an order can be rejected.\n  </p>\n</div>"
        },
        {
          "id": "disabled",
          "name": "Disabled and read-only",
          "html": "<div class=\"max-w-xl space-y-5\">\n  <div>\n    <label for=\"ta-off\" class=\"mb-1.5 block text-[13px]/5 font-medium text-zinc-500\">Terms and conditions</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-zinc-100\">\n      <textarea id=\"ta-off\" rows=\"3\" disabled\n                class=\"block w-full resize-none bg-transparent px-3 py-2 text-[14px]/5 text-zinc-400\">Set by the rate contract. Editable only on the contract itself.</textarea>\n    </div>\n  </div>\n\n  <div>\n    <!-- Same locked surface as disabled, and deliberately so: a filled, ringless\n         box reads as a field that is closed. The only thing separating the two\n         is the text, zinc-900 here against zinc-400 above, because a read-only\n         value still matters and still has to be selectable and copyable. On a\n         white bordered box with a focus ring, nothing says read-only until you\n         click into it and nothing happens. -->\n    <label for=\"ta-ro\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor reply</label>\n    <div class=\"rounded-lg border border-zinc-200 bg-zinc-100\">\n      <textarea id=\"ta-ro\" rows=\"3\" readonly\n                class=\"block w-full resize-none bg-transparent px-3 py-2 text-[14px]/5 outline-none\">Balance 40 kg dispatched on 18 August by Gati, LR 4471029.\nTest certificate follows by email.</textarea>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form field",
          "html": "<!-- The border being on the wrapper is what keeps the error state in the\n     template. The widget class is written once in forms.py and never has to be\n     rewritten in __init__ to add a red border, because the red border is not on\n     the widget.\n\n     # forms.py\n     class OrderNoteForm(forms.ModelForm):\n         class Meta:\n             model = OrderNote\n             fields = ['body']\n             widgets = {\n                 'body': forms.Textarea(attrs={\n                     'rows': 4,\n                     'placeholder': 'Add a note for whoever picks this up next',\n                     'class': 'block w-full bg-transparent px-3 py-2 text-[14px]/5 '\n                              'outline-none placeholder:text-zinc-500',\n                 })\n             }\n\n     A max_length on the model renders as maxlength on the widget, which\n     truncates a paste in silence. Either drop it from the widget and let\n     clean() reject the value with a message, or say the number in help_text\n     before anyone reaches it. form.body.field.max_length is the number. -->\n<form method=\"post\" class=\"max-w-xl\">\n  {% csrf_token %}\n  <div>\n    <label for=\"{{ form.body.id_for_label }}\" class=\"mb-1.5 block text-[13px]/5 font-medium\">\n      {{ form.body.label }}{% if form.body.field.required %} <span class=\"text-red-600\">*</span>{% endif %}\n    </label>\n\n    <div class=\"rounded-lg bg-white {% if form.body.errors %}border border-red-600 focus-within:ring-3 focus-within:ring-red-600/15{% else %}border border-zinc-200 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15{% endif %}\">\n      {{ form.body }}\n    </div>\n\n    {% if form.body.errors %}\n      <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>{{ form.body.errors.0 }}\n      </p>\n    {% elif form.body.help_text %}\n      <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">{{ form.body.help_text }}</p>\n    {% endif %}\n  </div>\n\n  <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Post note</button>\n</form>"
        }
      ]
    },
    {
      "id": "checkbox",
      "name": "Checkbox",
      "category": "forms",
      "description": "An independent on or off — one flag, a set of options, or the row selection a register's bulk actions run on. A real native box wearing accent-zinc-700.",
      "when_to_use": "A choice that does not depend on the ones beside it, and any list someone acts on several rows of at once. For one of a set use a radio group, and for a setting that applies the moment it is touched use a toggle.",
      "rules": [
        "Every box here is a real <input type=\"checkbox\"> with accent-zinc-700, and nothing uses appearance-none. Repainting the box yourself costs three things in one go: the mixed glyph, which no CSS brings back; the platform focus ring on a 16px target; and forced-colours mode, where a restyled box renders as an empty square with no tick in it. accent-color changes the fill and the tick and keeps all of it.",
        "While appearance is auto the box ignores border-radius and border-*, so the rounded in a size-4 rounded accent-zinc-700 is inert — it is not doing the thing its name promises, and nobody finds that out until they try to reshape the box with it. A size and accent-* are the whole styling surface; wanting more than that means appearance-none, which costs what the rule above costs.",
        "indeterminate is a JS property with no matching attribute, so it cannot be written in markup and never survives a server render. A page that comes back with two of five boxes ticked shows a select-all that reads as plain unchecked until script sets it — bind it with x-effect on the element itself, not on the component root, where $refs is not populated yet at init.",
        "Do not add aria-checked=\"mixed\" to a native box. The indeterminate property already maps to mixed, aria-checked belongs to a role=\"checkbox\" widget you built yourself, and an attribute set once outlives the property that keeps changing — which is how a select-all ends up announced as mixed long after everything was ticked.",
        "A select-all gets no name and no value. It is a control over the list, not a field in the form: indeterminate changes nothing about what is submitted, so a mixed select-all with a name posts exactly as if it were plainly ticked or plainly absent.",
        "An unticked box submits nothing at all — the name simply does not appear in the POST. Django reads that absence as False, which is only safe while the form renders every field it cleans; a POST assembled from a subset of the fields silently clears every boolean it left out. A group of boxes sharing a name arrives as a list, so read it with request.POST.getlist('notify') — .get() returns the last one and drops the rest.",
        "forms.BooleanField is required=True by default, and on a checkbox that means \"must be ticked\". A flag that is allowed to be off is required=False, or the form comes back invalid with \"This field is required\" the first time someone leaves it alone.",
        "The label wraps the box as its direct child. The base cursor rule matches label:has(> input[type=\"checkbox\"]), so wrapping the box in a div for layout drops the pointer without a word — put the flex on the label itself. The label is also what makes the target big enough: 16px of box is under the 24px WCAG 2.2 asks for, and padding on the label is what closes the gap.",
        "Help text goes outside the label, aligned to the text with pl-[26px], and is pointed at with aria-describedby. Anything inside the label is part of the box's accessible name, so a two-line explanation gets read back in full every time focus lands on the box. The same applies to a link — a link inside the label toggles the box when it is clicked.",
        "Never write outline-none on a box. The focus-within halo that text fields use is a wrapper trick; a 3px ring around a 16px square lands on top of the square and reads as a smudge. Keep the UA ring, and where a whole tile has to show focus put it on the tile with has-[:focus-visible].",
        "A select-all means this page, not the query behind it. Say the number out loud — \"5 of 5 on this page\" — and make selecting the other 4,312 a separate, deliberate click. A bulk approve that quietly took every match is not recoverable by pressing Undo.",
        "Paint the selected row with has-[:checked] rather than a second copy of the state, so the tint cannot disagree with the box. Guard the hover tint with [&:not(:has(:checked))] while you are there: hover and selected are one class each at equal specificity, so without the guard which one wins on a hovered selected row is decided by the order Tailwind emits the variants in.",
        "Never render the same rows twice with the same name — the desktop table and the stacked cards below md are both in the DOM, one of them display:none, and a display:none checkbox still posts. Either the boxes carry no name and the selection is Alpine state, as it is here, or one list is rendered and restyled."
      ],
      "anatomy": [
        {
          "part": "Box",
          "description": "A real input type=\"checkbox\", size-4 accent-zinc-700 shrink-0. No wrapper, no appearance-none, no substitute."
        },
        {
          "part": "Label",
          "description": "Wrapping the box as its direct child, so the text is part of the target. items-start with mt-0.5 on the box the moment the text can run to two lines — items-center floats a 16px square against the middle of a three-line block."
        },
        {
          "part": "Help text",
          "description": "A 12px zinc-500 line outside the label, indented to the text with pl-[26px] and named by aria-describedby."
        },
        {
          "part": "Group",
          "description": "A fieldset with a legend, one name repeated across the options, and one help line under the whole group rather than one under every row."
        },
        {
          "part": "Select-all",
          "description": "A box with no name and no value of its own. It reads all, some or none off the count, and writes indeterminate back through script."
        },
        {
          "part": "Selected row",
          "description": "bg-zinc-100 through has-[:checked], so the tint is the box's own state and not a second copy of it."
        },
        {
          "part": "Bulk bar",
          "description": "The strip above the rows once something is selected: how many, what will happen to them, and the way back out."
        }
      ],
      "behaviour": [
        "Clicking anywhere on the label toggles the box, so the target is the whole row and not the 16px square. Text merely sitting beside the box leaves a target that misses on a phone.",
        "A select-all reads three states off the count — none, some, all — and the middle one is indeterminate rather than unchecked. Ticking it takes every row on the page and nothing beyond it; unticking it releases only those rows.",
        "Shift-clicking runs from the last box touched to the one clicked and applies the state of the box clicked, so a run can be cleared the same way it was set. The handler clears the text selection the browser draws across the rows at the same time.",
        "The selection is an array of record ids on the component root, so the count, the row tint and the bulk bar cannot drift apart, and re-sorting the rows does not lose it.",
        "The bulk bar appears only once something is selected, names the number it will act on, and is x-cloaked so it is not on screen for the first frame.",
        "A group posts its name once for every ticked box and not at all when none are ticked. The difference between \"none of them\" and \"the field was never on this form\" comes from the form definition on the server, never from the request.",
        "A disabled option keeps its place in the list rather than disappearing, so a policy locking one does not change the shape of a set people have learned to scan."
      ],
      "accessibility": [
        "Every box has a name of its own. In a register that is aria-label naming the record — \"Select PO-24-1187\" — because twelve boxes all called \"Select\" say nothing about which row the cursor is on.",
        "A set of related boxes is a fieldset with a legend, or every option is announced with no question attached to it.",
        "The mixed state is the indeterminate property on a native input, which is already mapped to mixed. Nothing here writes aria-checked, and nothing needs to.",
        "No box carries outline-none. The UA focus ring is the only indicator that survives forced-colours mode, and on a 16px control there is no room to draw a better one.",
        "Disabled uses the disabled attribute, which drops the box out of the Tab order and out of the POST. There is no read-only checkbox — readonly does nothing on one — so a value that must not change is rendered as text, and one that must still be submitted gets a hidden input beside it.",
        "The selected count is plain text and not a live region. The box announces its own state on every toggle already; a live count makes that two announcements per keystroke and a stream of them on a shift-click.",
        "An error is real text under the group, referenced with aria-describedby, and a required single box carries both required and aria-invalid. A red asterisk on its own is decoration."
      ],
      "related": [
        "radio",
        "toggle",
        "table"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- The help text sits outside the label. Inside it, it becomes part of the\n     box's accessible name, and the whole sentence is read back every time focus\n     lands on the box. -->\n<div class=\"max-w-xl\">\n  <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n    <input type=\"checkbox\" id=\"cb-notify\" name=\"notify_vendor\" value=\"1\" checked\n           aria-describedby=\"cb-notify-help\"\n           class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n    <span>Email the vendor when this order is approved</span>\n  </label>\n  <p id=\"cb-notify-help\" class=\"mt-1 pl-[26px] text-[12px]/4 text-zinc-500\">\n    Goes to the contact on the rate contract, with the order PDF attached.\n  </p>\n</div>"
        },
        {
          "id": "group",
          "name": "Group",
          "html": "<!-- One name across the group. Two ticks post notify twice; no ticks post\n     nothing at all, and the server reads that absence off the form definition\n     rather than off the request. -->\n<fieldset class=\"max-w-xl\">\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Notify when this order is approved</legend>\n\n  <div class=\"space-y-2\">\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" name=\"notify\" value=\"buyer\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Ritu Deshpande — buyer</span>\n    </label>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" name=\"notify\" value=\"stores\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Anil Kulkarni — stores</span>\n    </label>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" name=\"notify\" value=\"vendor\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Gujarat Polymers Ltd — vendor</span>\n    </label>\n    <!-- locked, and still in its place: dropping it would change the shape of a\n         list people scan by position -->\n    <label class=\"flex items-start gap-2.5 text-[14px]/5 text-zinc-500\">\n      <input type=\"checkbox\" name=\"notify\" value=\"plant_head\" disabled class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"tabular-nums\">Plant head — orders above ₹10,00,000 only</span>\n    </label>\n  </div>\n\n  <!-- one help line under the group, never one under each option -->\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Everyone ticked gets the approved order as a PDF within the hour.</p>\n</fieldset>"
        },
        {
          "id": "indeterminate",
          "name": "Select all and mixed",
          "html": "<!-- indeterminate is a property, not an attribute. It cannot be written in\n     markup, so a fieldset rendered server-side with two of four lines ticked\n     comes back with a select-all that reads as plainly unchecked. x-effect sits\n     on the element itself rather than on the fieldset, because $refs is not\n     populated when the root initialises.\n\n     The select-all carries no name: indeterminate changes nothing about what is\n     submitted, so a mixed box with a name posts as if it were simply ticked. -->\n<fieldset class=\"max-w-xl\"\n          x-data=\"{\n            lines: ['hdpe', 'ldpe', 'mb', 'ao'],\n            sel: ['hdpe', 'mb'],\n            get every() { return this.sel.length === this.lines.length; },\n            get some() { return this.sel.length > 0 && !this.every; },\n            toggleAll(on) { this.sel = on ? [...this.lines] : []; }\n          }\">\n  <legend class=\"mb-2 text-[13px]/5 font-medium tabular-nums\">Lines to receive on GRN-24-0912</legend>\n\n  <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 pb-2\">\n    <label class=\"flex items-center gap-2.5 text-[13px]/5 font-medium\">\n      <input type=\"checkbox\" aria-label=\"Select all four lines\"\n             :checked=\"every\" x-effect=\"$el.indeterminate = some\"\n             @change=\"toggleAll($event.target.checked)\"\n             class=\"size-4 shrink-0 accent-zinc-700\">\n      <span>All lines</span>\n    </label>\n    <!-- the count sits outside the label, or it becomes part of the box's name\n         and is read back in full on every toggle -->\n    <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"sel.length + ' of 4 selected'\"></span>\n  </div>\n\n  <div class=\"mt-2 space-y-2\">\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"checkbox\" name=\"line\" value=\"hdpe\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">HDPE granules — grade M60075</span>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">2,000 kg</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"checkbox\" name=\"line\" value=\"ldpe\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">LDPE granules — grade 24FS040</span>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">800 kg</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"checkbox\" name=\"line\" value=\"mb\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Masterbatch, black</span>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">120 kg</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"checkbox\" name=\"line\" value=\"ao\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Antioxidant AO-168</span>\n      <span class=\"shrink-0 tabular-nums text-zinc-600\">25 kg</span>\n    </label>\n  </div>\n</fieldset>"
        },
        {
          "id": "cards",
          "name": "Option tiles",
          "html": "<!-- No Alpine at all. :has() paints the tile from the box's own state, so the\n     tint can never disagree with what is ticked, and has-[:focus-visible] puts\n     the focus indication where it can actually be seen — a 3px halo drawn round\n     a 16px square lands on top of the square.\n\n     The hover tint carries the [&:not(:has(:checked))] guard: hover and selected\n     are one class each at equal specificity, and without it which one paints a\n     hovered selected tile depends on the order Tailwind emits the variants. -->\n<fieldset>\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Send with the vendor email</legend>\n\n  <div class=\"grid gap-2 sm:grid-cols-3\">\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"checkbox\" name=\"enclose\" value=\"po\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">Purchase order</span>\n        <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-500\">PDF · 2 pages</span>\n      </span>\n    </label>\n\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"checkbox\" name=\"enclose\" value=\"drawings\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">Drawing set</span>\n        <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-500\">PDF · 14 sheets</span>\n      </span>\n    </label>\n\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"checkbox\" name=\"enclose\" value=\"contract\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">Rate contract extract</span>\n        <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-500\">Valid to 31 Mar 2025</span>\n      </span>\n    </label>\n  </div>\n</fieldset>"
        },
        {
          "id": "filters",
          "name": "Filter list",
          "html": "<!-- A facet list: one repeated name, a count against each option, and a ceiling\n     on the height so twenty vendors do not push the register off the screen. The\n     vendor name truncates and the count is shrink-0, so nothing reflows at\n     390px and the digits still line up. -->\n<div class=\"max-w-xs rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{ sel: ['gujarat-polymers', 'sharma-extrusions'] }\">\n  <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 px-4 py-2.5\">\n    <h3 class=\"text-[13px]/5 font-medium\">Vendor</h3>\n    <button type=\"button\" x-show=\"sel.length\" x-cloak @click=\"sel = []\"\n            class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-600 underline underline-offset-2\">\n      Clear <span x-text=\"sel.length\"></span>\n    </button>\n  </div>\n\n  <fieldset class=\"max-h-56 overflow-y-auto px-4 py-3\">\n    <legend class=\"sr-only\">Filter orders by vendor</legend>\n    <div class=\"space-y-2\">\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"gujarat-polymers\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Gujarat Polymers Ltd</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">128</span>\n      </label>\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"sharma-extrusions\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Sharma Extrusions</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">96</span>\n      </label>\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"nashik-steel\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Nashik Steel Traders</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">74</span>\n      </label>\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"silvassa-packaging\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Silvassa Packaging and Allied Products</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">41</span>\n      </label>\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"konkan-chemicals\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Konkan Chemicals Pvt Ltd</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">18</span>\n      </label>\n      <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" name=\"vendor\" value=\"baroda-fasteners\" x-model=\"sel\" class=\"size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1 truncate\">Baroda Fasteners</span>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">9</span>\n      </label>\n    </div>\n  </fieldset>\n\n  <div class=\"border-t border-zinc-200 px-4 py-2.5\">\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\"\n       x-text=\"sel.length ? sel.length + ' of 6 vendors' : 'No vendor filter · all 1,438 orders'\"></p>\n  </div>\n</div>"
        },
        {
          "id": "table",
          "name": "Row selection",
          "html": "<!-- Selection is an array of PO numbers on the root, and the row tint is\n     has-[:checked] reading the box itself, so the two cannot drift apart.\n\n     The boxes carry no name on purpose: the table and the stacked cards are both\n     in the DOM below md, one of them display:none, and a display:none checkbox\n     still posts. Two renderings of one row with one name post that row twice.\n\n     Shift-click runs from the last box touched to the one clicked and takes the\n     state of the box clicked, so a run can be cleared as easily as set. The\n     browser draws a text selection across the rows while it does that, which the\n     handler clears.\n\n     Select-all means this page. The other 4,312 are a second, deliberate click,\n     because a bulk approve that quietly took every match cannot be undone. -->\n<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{\n       ids: ['PO-24-1187', 'PO-24-1191', 'PO-24-1194', 'PO-24-1203', 'PO-24-1206'],\n       sel: ['PO-24-1191'],\n       last: null,\n       scope: 'page',\n       get every() { return this.sel.length === this.ids.length; },\n       get some() { return this.sel.length > 0 && !this.every; },\n       has(id) { return this.sel.includes(id); },\n       set(id, on) {\n         const at = this.sel.indexOf(id);\n         if (on && at < 0) this.sel.push(id);\n         if (!on && at > -1) this.sel.splice(at, 1);\n       },\n       pick(i, e) {\n         const on = e.target.checked;\n         const from = (e.shiftKey && this.last !== null) ? this.last : i;\n         for (let k = Math.min(from, i); k <= Math.max(from, i); k++) this.set(this.ids[k], on);\n         if (e.shiftKey) window.getSelection().removeAllRanges();\n         this.last = i;\n         this.scope = 'page';\n       },\n       page(on) { this.sel = on ? [...this.ids] : []; this.last = null; this.scope = 'page'; },\n       clear() { this.sel = []; this.last = null; this.scope = 'page'; }\n     }\">\n\n  <div x-show=\"sel.length\" x-cloak\n       class=\"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-zinc-200 bg-zinc-100 px-4 py-2\">\n    <span class=\"text-[13px]/5 font-medium tabular-nums\">\n      <span x-show=\"scope === 'page'\"><span x-text=\"sel.length\"></span> of 5 on this page selected</span>\n      <span x-show=\"scope === 'query'\" x-cloak>All 4,312 matching orders selected</span>\n    </span>\n    <div class=\"flex flex-wrap items-center gap-2\">\n      <button type=\"button\" class=\"rounded-lg bg-zinc-700 px-3 py-1.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n      <button type=\"button\" class=\"rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">Export</button>\n      <button type=\"button\" class=\"rounded-lg px-3 py-1.5 text-[13px]/5 font-medium text-red-600 hover:bg-zinc-100\">Cancel orders</button>\n    </div>\n    <button type=\"button\" x-show=\"every && scope === 'page'\" x-cloak @click=\"scope = 'query'\"\n            class=\"text-[13px]/5 tabular-nums text-zinc-900 underline underline-offset-2\">Select all 4,312 matching orders</button>\n    <button type=\"button\" @click=\"clear()\" class=\"ml-auto shrink-0 text-[13px]/5 text-zinc-600 underline underline-offset-2\">Clear</button>\n  </div>\n\n  <table class=\"hidden w-full text-[13px]/5 md:table\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"w-10 px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select all 5 orders on this page\"\n                 :checked=\"every\" x-effect=\"$el.indeterminate = some\"\n                 @change=\"page($event.target.checked)\"\n                 class=\"size-4 accent-zinc-700\">\n        </th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">PO number</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Amount</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Status</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Due</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select PO-24-1187\"\n                 :checked=\"has('PO-24-1187')\" @click=\"pick(0, $event)\" class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹18,42,000</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n            <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-600\">12 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select PO-24-1191\"\n                 :checked=\"has('PO-24-1191')\" @click=\"pick(1, $event)\" class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1191</td>\n        <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹4,68,500</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n            <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-600\">19 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select PO-24-1194\"\n                 :checked=\"has('PO-24-1194')\" @click=\"pick(2, $event)\" class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194</td>\n        <td class=\"px-4 py-2.5\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹27,10,400</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n            <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-600\">02 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select PO-24-1203\"\n                 :checked=\"has('PO-24-1203')\" @click=\"pick(3, $event)\" class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1203</td>\n        <td class=\"px-4 py-2.5\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹96,750</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n            <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Closed\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-600\">28 Jul</td>\n      </tr>\n      <tr class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"checkbox\" aria-label=\"Select PO-24-1206\"\n                 :checked=\"has('PO-24-1206')\" @click=\"pick(4, $event)\" class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1206</td>\n        <td class=\"px-4 py-2.5\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">₹1,32,900</td>\n        <td class=\"px-4 py-2.5\">\n          <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-600 ring-1 ring-inset ring-zinc-300\">\n            <span class=\"size-1.5 rounded-full bg-zinc-400\" aria-hidden=\"true\"></span>Draft\n          </span>\n        </td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-500\">—</td>\n      </tr>\n    </tbody>\n  </table>\n\n  <!-- below md the same five records, as cards. Same fields, same order, no\n       sideways scroll, and the select-all comes with them. -->\n  <div class=\"border-b border-zinc-200 px-4 py-2.5 md:hidden\">\n    <label class=\"flex items-center gap-2.5 text-[13px]/5 font-medium\">\n      <input type=\"checkbox\" aria-label=\"Select all 5 orders on this page\"\n             :checked=\"every\" x-effect=\"$el.indeterminate = some\"\n             @change=\"page($event.target.checked)\"\n             class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"tabular-nums\">All 5 on this page</span>\n    </label>\n  </div>\n  <ul class=\"divide-y divide-zinc-100 md:hidden\">\n    <li class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n      <label class=\"flex items-start gap-3 px-4 py-3\">\n        <input type=\"checkbox\" aria-label=\"Select PO-24-1187\"\n               :checked=\"has('PO-24-1187')\" @click=\"pick(0, $event)\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"flex items-baseline justify-between gap-3\">\n            <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1187</span>\n            <span class=\"shrink-0 text-[14px]/5 tabular-nums\">₹18,42,000</span>\n          </span>\n          <span class=\"mt-0.5 block text-[13px]/5 text-zinc-600\">Sharma Extrusions</span>\n          <span class=\"mt-2 flex items-center gap-2\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n            </span>\n            <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">Due 12 Aug</span>\n          </span>\n        </span>\n      </label>\n    </li>\n    <li class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n      <label class=\"flex items-start gap-3 px-4 py-3\">\n        <input type=\"checkbox\" aria-label=\"Select PO-24-1191\"\n               :checked=\"has('PO-24-1191')\" @click=\"pick(1, $event)\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"flex items-baseline justify-between gap-3\">\n            <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1191</span>\n            <span class=\"shrink-0 text-[14px]/5 tabular-nums\">₹4,68,500</span>\n          </span>\n          <span class=\"mt-0.5 block text-[13px]/5 text-zinc-600\">Nashik Steel Traders</span>\n          <span class=\"mt-2 flex items-center gap-2\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n            </span>\n            <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">Due 19 Aug</span>\n          </span>\n        </span>\n      </label>\n    </li>\n    <li class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n      <label class=\"flex items-start gap-3 px-4 py-3\">\n        <input type=\"checkbox\" aria-label=\"Select PO-24-1194\"\n               :checked=\"has('PO-24-1194')\" @click=\"pick(2, $event)\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"flex items-baseline justify-between gap-3\">\n            <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1194</span>\n            <span class=\"shrink-0 text-[14px]/5 tabular-nums\">₹27,10,400</span>\n          </span>\n          <span class=\"mt-0.5 block text-[13px]/5 text-zinc-600\">Gujarat Polymers Ltd</span>\n          <span class=\"mt-2 flex items-center gap-2\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n            </span>\n            <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">Due 02 Aug</span>\n          </span>\n        </span>\n      </label>\n    </li>\n    <li class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n      <label class=\"flex items-start gap-3 px-4 py-3\">\n        <input type=\"checkbox\" aria-label=\"Select PO-24-1203\"\n               :checked=\"has('PO-24-1203')\" @click=\"pick(3, $event)\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"flex items-baseline justify-between gap-3\">\n            <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1203</span>\n            <span class=\"shrink-0 text-[14px]/5 tabular-nums\">₹96,750</span>\n          </span>\n          <span class=\"mt-0.5 block text-[13px]/5 text-zinc-600\">Sharma Extrusions</span>\n          <span class=\"mt-2 flex items-center gap-2\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Closed\n            </span>\n            <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">Due 28 Jul</span>\n          </span>\n        </span>\n      </label>\n    </li>\n    <li class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n      <label class=\"flex items-start gap-3 px-4 py-3\">\n        <input type=\"checkbox\" aria-label=\"Select PO-24-1206\"\n               :checked=\"has('PO-24-1206')\" @click=\"pick(4, $event)\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"flex items-baseline justify-between gap-3\">\n            <span class=\"text-[14px]/5 font-medium tabular-nums\">PO-24-1206</span>\n            <span class=\"shrink-0 text-[14px]/5 tabular-nums\">₹1,32,900</span>\n          </span>\n          <span class=\"mt-0.5 block text-[13px]/5 text-zinc-600\">Nashik Steel Traders</span>\n          <span class=\"mt-2 flex items-center gap-2\">\n            <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[12px]/4 text-zinc-600 ring-1 ring-inset ring-zinc-300\">\n              <span class=\"size-1.5 rounded-full bg-zinc-400\" aria-hidden=\"true\"></span>Draft\n            </span>\n            <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">No due date</span>\n          </span>\n        </span>\n      </label>\n    </li>\n  </ul>\n</div>"
        },
        {
          "id": "states",
          "name": "Disabled, locked and invalid",
          "html": "<div class=\"max-w-xl space-y-5\">\n  <!-- disabled: out of the Tab order and out of the POST -->\n  <div>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5 text-zinc-500\">\n      <input type=\"checkbox\" disabled class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Auto-close the order on a full GRN</span>\n    </label>\n    <p class=\"mt-1 pl-[26px] text-[12px]/4 text-zinc-500\">Locked by plant policy. It changes on the rate contract, not here.</p>\n  </div>\n\n  <!-- ticked and locked. disabled submits nothing at all, so a value that is\n       already true and still has to reach the server travels in a hidden input\n       beside the box. -->\n  <div>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5 text-zinc-500\">\n      <input type=\"checkbox\" checked disabled class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Three-way match against the invoice</span>\n    </label>\n    <input type=\"hidden\" name=\"three_way_match\" value=\"1\">\n    <p class=\"mt-1 pl-[26px] text-[12px]/4 tabular-nums text-zinc-500\">Compulsory above ₹10,00,000.</p>\n  </div>\n\n  <!-- There is no read-only checkbox: readonly does nothing on one, so a box\n       left readonly is a box anyone can still tick. A value nobody may change is\n       not a control — render it. -->\n  <div>\n    <p class=\"text-[13px]/5 font-medium text-zinc-600\">Partial receipt</p>\n    <p class=\"mt-1 flex items-center gap-2 text-[14px]/5\">\n      <i data-lucide=\"check\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      Allowed — set when the order was released\n    </p>\n  </div>\n\n  <!-- required and unticked. The asterisk is decoration; required and\n       aria-invalid are what is actually announced. -->\n  <div>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" id=\"cb-declare\" name=\"declared\" required\n             aria-invalid=\"true\" aria-describedby=\"cb-declare-err\"\n             class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"tabular-nums\">I have counted this material against GRN-24-0912 <span class=\"text-red-600\">*</span></span>\n    </label>\n    <p id=\"cb-declare-err\" class=\"mt-1 flex items-start gap-1.5 pl-[26px] text-[12px]/4 font-medium text-red-600\">\n      <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>\n      The declaration has to be ticked before the GRN can be posted.\n    </p>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form field",
          "html": "<!-- forms.py\n     class ApprovalForm(forms.Form):\n         # required=True on a BooleanField means \"must be ticked\", so a flag that\n         # is allowed to be off is required=False. Left at the default, the form\n         # comes back invalid with \"This field is required\" the first time\n         # somebody leaves the box alone.\n         notify_vendor = forms.BooleanField(\n             required=False, initial=True,\n             label='Email the vendor when this order is approved',\n             help_text='Goes to the contact on the rate contract.',\n             widget=forms.CheckboxInput(attrs={\n                 'class': 'mt-0.5 size-4 shrink-0 accent-zinc-700',\n             }))\n\n         notify = forms.MultipleChoiceField(\n             required=False, choices=NOTIFY_CHOICES, label='Also notify',\n             widget=forms.CheckboxSelectMultiple(attrs={\n                 'class': 'mt-0.5 size-4 shrink-0 accent-zinc-700',\n             }))\n\n     An unticked box sends nothing at all — the name is simply absent from the\n     POST, and CheckboxInput.value_from_datadict reads that absence as False.\n     That is only safe while the form renders every field it cleans: a POST\n     assembled from a subset of the fields clears every boolean it left out.\n\n     Loop the bound field rather than printing {{ form.notify }}. The widget\n     brings its own wrapper markup — a div per option since Django 4.0, a ul\n     before that — and no amount of attrs will lay it out. Each iteration yields\n     the input as {{ choice.tag }} and its text as {{ choice.choice_label }}. -->\n<form method=\"post\" class=\"max-w-xl\">\n  {% csrf_token %}\n\n  <div>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      {{ form.notify_vendor }}<span>{{ form.notify_vendor.label }}</span>\n    </label>\n    {% if form.notify_vendor.help_text %}\n      <p class=\"mt-1 pl-[26px] text-[12px]/4 text-zinc-500\">{{ form.notify_vendor.help_text }}</p>\n    {% endif %}\n  </div>\n\n  <fieldset class=\"mt-5\">\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">{{ form.notify.label }}</legend>\n    <div class=\"space-y-2\">\n      {% for choice in form.notify %}\n        <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n          {{ choice.tag }}<span>{{ choice.choice_label }}</span>\n        </label>\n      {% endfor %}\n    </div>\n    {% if form.notify.errors %}\n      <p class=\"mt-2 flex items-start gap-1.5 text-[12px]/4 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>{{ form.notify.errors.0 }}\n      </p>\n    {% endif %}\n  </fieldset>\n\n  <!-- Row selection is one repeated name, read back with\n       request.POST.getlist('ids'). .get('ids') returns the last one and drops\n       every other tick on the page. This list is rendered once — render it a\n       second time for a phone layout and every ticked row posts twice, because a\n       display:none checkbox still submits. -->\n  <fieldset class=\"mt-5\">\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">Orders to approve</legend>\n    <div class=\"space-y-2\">\n      {% for order in page_obj %}\n        <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n          <input type=\"checkbox\" name=\"ids\" value=\"{{ order.number }}\"\n                 {% if order.number in selected %}checked{% endif %}\n                 class=\"size-4 shrink-0 accent-zinc-700\">\n          <span class=\"min-w-0 flex-1 truncate tabular-nums\">{{ order.number }} · {{ order.vendor }}</span>\n          <span class=\"shrink-0 tabular-nums text-zinc-600\">₹{{ order.amount }}</span>\n        </label>\n      {% endfor %}\n    </div>\n  </fieldset>\n\n  <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve selected</button>\n</form>"
        }
      ]
    },
    {
      "id": "radio",
      "name": "Radio",
      "category": "forms",
      "description": "One of a set, and exactly one. Real native radios wearing accent-zinc-700, where the shared name is the group and the only way to mean none is an option that says so.",
      "when_to_use": "A question with one answer and a handful of options worth showing at once — payment terms, who inspects, which order a GRN is raised against. Options that do not exclude each other are checkboxes; a setting that applies the moment it is touched is a toggle; past about seven options, or with labels longer than a few words, it is a select.",
      "rules": [
        "The name is the group. Not the fieldset, not the layout — grouping is by name within the form, so two fieldsets sharing a name are one question, and one typo in one option's name makes that option independently selectable. Nothing looks wrong until two are on at once and the POST carries an answer nobody chose.",
        "Every option carries a value. A radio without one posts on, exactly like every other option in the group, so the server receives priority=on and has no way to tell which of the three was picked — and the bug survives testing, because the field is present and the form validates.",
        "A radio cannot be unticked. Clicking the chosen option again does nothing, and a Clear button that unchecks it in script leaves the group posting no key at all, which the server cannot tell apart from a question nobody was asked. If \"none\", \"any\" or \"not required\" is a real answer it is an option with a value of its own; if the answer is genuinely independent it was a checkbox.",
        "Arrow keys move and select in one action, so a keyboard user passes through every option on the way to the one they want. Never hang anything irreversible off the change event — a group that fires a save, a fetch or a recalculation per keystroke fires it three times before the intended answer lands.",
        "Roving focus is native. The group is one Tab stop, the arrows wrap at both ends and skip disabled options, and none of it needs a line of code. Adding role=\"radio\", aria-checked or tabindex to native inputs replaces working behaviour with a hand-written copy of it — that machinery belongs to the segmented track in button-group, whose options are buttons and have no semantics of their own.",
        "With nothing checked, the group is still one Tab stop and Tab lands on the first option. Checking one by default moves the tab target to it and changes what a bare POST means: everybody who never read the question has now answered it. Preselect the safe, common case, and preselect nothing at all when the choice has consequences.",
        "required on any one option applies to the whole group — the browser will not submit until something in it is checked. An unanswered group posts nothing: the key is absent, not empty, so a server that reads request.POST.get('priority') gets None and a form field that was never rendered looks identical to a question that was skipped.",
        "Never disable a whole group to mean read-only. There is no readonly on a radio, and disabled options are excluded from submission, so a locked group posts nothing and clears the stored value on the next save. Render the settled answer as text, and add a hidden input beside it when it still has to travel.",
        "A register picks one row with one name across every row, and a nested list needs the line id inside the name — name=\"qc_{{ line.id }}\" — or every line in the table becomes one group and the whole GRN can only have one result. Unlike a checkbox register the rows cannot be rendered twice for a phone layout: two copies sharing a name are a single group, and the checked option in the display:none copy wins.",
        "Keep the input in an option tile. A tile that hides its radio with sr-only and paints the choice as a tint alone loses the mark entirely in forced-colours mode, where the tint is not rendered — and the tint is exactly what has-[:checked] should be reading off the box, not replacing it.",
        "size-4 accent-zinc-700 shrink-0 and nothing else. rounded-full on a radio is inert — while appearance is auto the control ignores border-radius, and the box is already a circle — and appearance-none costs what the checkbox page says it costs.",
        "Django's RadioSelect has rendered a <div> per option since 4.0, a <ul><li> before that, so printing the field hands you that wrapper and no amount of attrs will lay it out. attrs are inherited by every option and also land on that wrapper div, which is how a size-4 class ends up sizing the container as well as the inputs. Loop the bound field instead."
      ],
      "anatomy": [
        {
          "part": "Option",
          "description": "A real input type=\"radio\", size-4 accent-zinc-700 shrink-0. No appearance-none, no rounded-full, no substitute drawn in CSS."
        },
        {
          "part": "Name",
          "description": "One string repeated across every option. This is what makes them exclusive — the fieldset is presentation, the name is the group."
        },
        {
          "part": "Value",
          "description": "What that option posts. Mandatory on every one of them: without it each option submits on and the answers are indistinguishable."
        },
        {
          "part": "Group",
          "description": "A fieldset whose legend is the question. Radios are never alone, so this is not optional the way it is for a single checkbox."
        },
        {
          "part": "Label",
          "description": "Wrapping the input as its direct child, so the text is part of the target and the row is not a 16px hit."
        },
        {
          "part": "Description",
          "description": "A 12px zinc-500 line per option, outside the label, indented with pl-[26px] and named by aria-describedby. Group-level help sits under the fieldset instead."
        },
        {
          "part": "Chosen tile",
          "description": "has-[:checked]:bg-zinc-100 with has-[:checked]:border-zinc-700, reading the box's own state so the tint cannot disagree with the answer."
        }
      ],
      "behaviour": [
        "The group is one Tab stop. Tab enters on the checked option, or on the first when none is checked, and the next Tab leaves the group entirely rather than walking through the rest of it.",
        "Arrow keys move focus and change the answer together, wrapping past both ends and stepping over disabled options. Space is not needed and does nothing extra.",
        "Clicking the chosen option again leaves it chosen. The only way out of an answer is another answer, which is why \"none\" has to be one of them.",
        "Choosing one option releases the previous one silently — there is no intermediate state, so a group can never post two values or an empty one.",
        "A group with nothing checked submits no key at all. The difference between \"none of them\" and \"the field was never on this form\" comes from the form definition on the server, never from the request.",
        "A disabled option keeps its place and its explanation. Dropping it changes the shape of a list people scan by position, and re-enabling it later shifts every option below it.",
        "In a register the choice is the row, so there is no select-all and no bulk bar: the action beneath applies to exactly one record, and it is named in the footer so the answer is readable without hunting for the filled dot."
      ],
      "accessibility": [
        "The fieldset and its legend are the question. Browsers already group the inputs by name and announce each option's position — \"45 days from invoice date, radio button, 2 of 3\" — but the group has no name at all without the legend, and \"2 of 3\" of what is then anybody's guess.",
        "No role=\"radiogroup\", no role=\"radio\", no aria-checked, no tabindex. Native radios carry all of it, and declaring the roles by hand means owning the roving tabindex and the arrow keys by hand too.",
        "Per-option help sits outside the label and is pointed at with aria-describedby. Inside the label it becomes part of that option's accessible name and is read back in full every time the arrow keys pass over it — three times on the way to the third option.",
        "required goes on every option in the group, not just the first, because only the focused option's own attribute is announced. Django does this for you: RadioSelect inherits attrs into each input.",
        "An error is real text under the group, referenced by aria-describedby from every option and paired with aria-invalid on each, since focus lands on one radio and only that one's description is read out.",
        "A radio in a register carries an aria-label naming the record — \"Raise the GRN against PO-24-1187\" — because ten radios all called \"Select\" say nothing about which row the cursor is on.",
        "No option carries outline-none. On a 16px control there is no room to draw a better focus ring than the UA one, and it is the only indicator that survives forced-colours mode."
      ],
      "related": [
        "checkbox",
        "toggle",
        "field"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- One name across the three options is the entire mechanism. Mistype it on\n     one of them and that option quietly stops excluding the others.\n\n     Nothing is preselected: this goes on the printed order, so it is a choice\n     somebody has to make rather than one that makes itself for anybody who\n     never read the question. -->\n<fieldset class=\"max-w-xl\">\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Payment terms</legend>\n\n  <div class=\"space-y-2\">\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"terms\" value=\"grn30\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"tabular-nums\">30 days from GRN</span>\n    </label>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"terms\" value=\"inv45\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"tabular-nums\">45 days from invoice date</span>\n    </label>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"terms\" value=\"advance\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span>Advance against proforma</span>\n    </label>\n  </div>\n\n  <!-- one help line under the group; per-option help goes under its own option -->\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Printed on the order. Anything outside these three needs an amendment to the rate contract.</p>\n</fieldset>"
        },
        {
          "id": "descriptions",
          "name": "Options with a description",
          "html": "<!-- Each description sits outside its label. Inside it, it becomes part of that\n     option's accessible name, and the whole sentence is read back every time the\n     arrow keys pass over the option — which, in a group, is on the way to every\n     option after it. -->\n<fieldset class=\"max-w-xl\">\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">How this order is priced</legend>\n\n  <div class=\"space-y-3\">\n    <div>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" id=\"rd-rc\" name=\"pricing\" value=\"contract\" checked\n               aria-describedby=\"rd-rc-help\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Rate contract</span>\n      </label>\n      <p id=\"rd-rc-help\" class=\"mt-1 pl-[26px] text-[12px]/4 tabular-nums text-zinc-500\">\n        RC-2024-11 with Gujarat Polymers Ltd, valid to 31 Mar 2025. Rates are locked and the buyer cannot edit them.\n      </p>\n    </div>\n\n    <div>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" id=\"rd-qt\" name=\"pricing\" value=\"quotation\"\n               aria-describedby=\"rd-qt-help\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Against a quotation</span>\n      </label>\n      <p id=\"rd-qt-help\" class=\"mt-1 pl-[26px] text-[12px]/4 tabular-nums text-zinc-500\">\n        QT-24-0388, received 04 Aug. Expires 30 days from receipt.\n      </p>\n    </div>\n\n    <div>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" id=\"rd-manual\" name=\"pricing\" value=\"manual\"\n               aria-describedby=\"rd-manual-help\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Manual rate</span>\n      </label>\n      <p id=\"rd-manual-help\" class=\"mt-1 pl-[26px] text-[12px]/4 tabular-nums text-zinc-500\">\n        Needs a second approval above ₹2,00,000 and a reason on the order.\n      </p>\n    </div>\n  </div>\n</fieldset>"
        },
        {
          "id": "cards",
          "name": "Option tiles",
          "html": "<!-- No Alpine. :has() paints each tile from its own radio, so the tint is the\n     answer rather than a second copy of it, and has-[:focus-visible] puts the\n     focus indication somewhere it can be seen — a 3px halo drawn round a 16px\n     circle lands on top of the circle.\n\n     The radio stays visible inside the tile. Hidden with sr-only, the only mark\n     of what was chosen is the tint, and forced-colours mode does not paint it.\n\n     The hover tint carries the [&:not(:has(:checked))] guard: hover and chosen\n     are one class each at equal specificity, and without it which one wins on a\n     hovered chosen tile depends on the order Tailwind emits the variants. -->\n<fieldset>\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Inspection before dispatch</legend>\n\n  <div class=\"grid gap-2 sm:grid-cols-3\">\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"radio\" name=\"inspection\" value=\"vendor\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">At vendor works</span>\n        <span class=\"mt-0.5 block text-[12px]/4 text-zinc-500\">Gujarat Polymers Ltd, Vapi</span>\n      </span>\n    </label>\n\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"radio\" name=\"inspection\" value=\"gate\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">At the plant gate</span>\n        <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-500\">Adds 1 day to the receipt</span>\n      </span>\n    </label>\n\n    <label class=\"flex items-start gap-2.5 rounded-xl border border-zinc-200 bg-white p-3 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:border-zinc-700 has-[:checked]:bg-zinc-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-zinc-700/15\">\n      <input type=\"radio\" name=\"inspection\" value=\"third_party\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0\">\n        <span class=\"block text-[13px]/5 font-medium\">Third-party agency</span>\n        <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-500\">₹12,000 per visit, billed to the order</span>\n      </span>\n    </label>\n  </div>\n</fieldset>"
        },
        {
          "id": "inline",
          "name": "On one row",
          "html": "<!-- A row only works while every label is a word or two and the set is short.\n     It wraps rather than scrolls at 390px, which is the whole reason gap-y is\n     set as well as gap-x.\n\n     Four one-word options that filter the view the moment they are touched are\n     the segmented track in button-group instead; this shape is for a value that\n     is part of the form and is saved with it. -->\n<fieldset>\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Quantity unit</legend>\n\n  <div class=\"flex flex-wrap gap-x-5 gap-y-2\">\n    <label class=\"flex items-center gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"uom\" value=\"kg\" checked class=\"size-4 shrink-0 accent-zinc-700\">\n      <span>kg</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"uom\" value=\"mt\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span>MT</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"uom\" value=\"nos\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span>Nos</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[14px]/5\">\n      <input type=\"radio\" name=\"uom\" value=\"ltr\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span>Litres</span>\n    </label>\n  </div>\n\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Applies to every line on this order. Changing it does not convert the quantities already entered.</p>\n</fieldset>"
        },
        {
          "id": "none",
          "name": "When none is an answer",
          "html": "<!-- A radio cannot be unticked. Clicking \"Gujarat Polymers Ltd\" a second time\n     leaves it chosen, and a Clear button that unchecks it in script leaves the\n     group posting no key at all — which the server cannot tell apart from a\n     filter that was never on the page.\n\n     So the way out is an option. \"Any vendor\" carries a value like every other\n     option, is the checked default, and is the only thing that makes this group\n     clearable at all. -->\n<fieldset class=\"max-w-xs\">\n  <legend class=\"mb-2 text-[13px]/5 font-medium\">Vendor</legend>\n\n  <div class=\"space-y-2\">\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"radio\" name=\"vendor\" value=\"\" checked class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Any vendor</span>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">1,438</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"radio\" name=\"vendor\" value=\"gujarat-polymers\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Gujarat Polymers Ltd</span>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">128</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"radio\" name=\"vendor\" value=\"sharma-extrusions\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Sharma Extrusions</span>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">96</span>\n    </label>\n    <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n      <input type=\"radio\" name=\"vendor\" value=\"silvassa-packaging\" class=\"size-4 shrink-0 accent-zinc-700\">\n      <span class=\"min-w-0 flex-1 truncate\">Silvassa Packaging and Allied Products</span>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">41</span>\n    </label>\n  </div>\n\n  <!-- The empty value is deliberate: it posts vendor= rather than nothing, so\n       the view can tell \"cleared the filter\" from \"never sent one\". -->\n  <p class=\"mt-2 text-[12px]/4 text-zinc-500\">One vendor at a time. To compare two, use the checkbox filter list.</p>\n</fieldset>"
        },
        {
          "id": "table",
          "name": "Picking one record",
          "html": "<!-- One name across every row is what makes this pick-one. Give each row its\n     own name and every row becomes independently selectable; put a radio group\n     inside each row of a nested list and the name needs the line id in it, or\n     the whole table is one group.\n\n     The rows are rendered once and columns drop out below md. A checkbox\n     register can render a second copy for phones and keep the selection in\n     Alpine, but a radio group cannot: two copies sharing a name are one group,\n     and the checked option in the display:none copy is the one that wins.\n\n     No select-all and no bulk bar. The answer is a single record, so it is named\n     in the footer instead — the filled dot alone is not readable at a glance. -->\n<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{ po: 'PO-24-1191' }\">\n  <table class=\"w-full text-[13px]/5\">\n    <thead>\n      <tr class=\"border-b border-zinc-200 text-left text-[11px]/4 font-medium tracking-wider text-zinc-600 uppercase\">\n        <th scope=\"col\" class=\"w-10 px-4 py-2.5\"><span class=\"sr-only\">Raise against</span></th>\n        <th scope=\"col\" class=\"px-4 py-2.5 font-medium\">Order</th>\n        <th scope=\"col\" class=\"hidden px-4 py-2.5 font-medium md:table-cell\">Vendor</th>\n        <th scope=\"col\" class=\"px-4 py-2.5 text-right font-medium\">Pending</th>\n        <th scope=\"col\" class=\"hidden px-4 py-2.5 text-right font-medium md:table-cell\">Due</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"radio\" name=\"grn_against\" value=\"PO-24-1187\" x-model=\"po\"\n                 aria-label=\"Raise the GRN against PO-24-1187\"\n                 class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1187\n          <span class=\"mt-0.5 block font-normal text-zinc-600 md:hidden\">Sharma Extrusions</span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 md:table-cell\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">2,000 kg</td>\n        <td class=\"hidden px-4 py-2.5 text-right tabular-nums text-zinc-600 md:table-cell\">12 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"radio\" name=\"grn_against\" value=\"PO-24-1191\" x-model=\"po\"\n                 aria-label=\"Raise the GRN against PO-24-1191\"\n                 class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1191\n          <span class=\"mt-0.5 block font-normal text-zinc-600 md:hidden\">Nashik Steel Traders</span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 md:table-cell\">Nashik Steel Traders</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">18 MT</td>\n        <td class=\"hidden px-4 py-2.5 text-right tabular-nums text-zinc-600 md:table-cell\">19 Aug</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100 [&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"radio\" name=\"grn_against\" value=\"PO-24-1194\" x-model=\"po\"\n                 aria-label=\"Raise the GRN against PO-24-1194\"\n                 class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums\">PO-24-1194\n          <span class=\"mt-0.5 block font-normal text-zinc-600 md:hidden\">Gujarat Polymers Ltd</span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 md:table-cell\">Gujarat Polymers Ltd</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums\">4,500 kg</td>\n        <td class=\"hidden px-4 py-2.5 text-right tabular-nums text-zinc-600 md:table-cell\">02 Aug</td>\n      </tr>\n      <!-- closed to receipt, and still in its place: dropping the row would\n           change the shape of a list people scan by position -->\n      <tr class=\"[&:not(:has(:checked))]:hover:bg-zinc-50 has-[:checked]:bg-zinc-100\">\n        <td class=\"px-4 py-2.5\">\n          <input type=\"radio\" name=\"grn_against\" value=\"PO-24-1203\" disabled\n                 aria-label=\"PO-24-1203, fully received, cannot be picked\"\n                 class=\"size-4 accent-zinc-700\">\n        </td>\n        <td class=\"px-4 py-2.5 font-medium tabular-nums text-zinc-500\">PO-24-1203\n          <span class=\"mt-0.5 block font-normal text-zinc-500 md:hidden\">Sharma Extrusions</span>\n        </td>\n        <td class=\"hidden px-4 py-2.5 text-zinc-500 md:table-cell\">Sharma Extrusions</td>\n        <td class=\"px-4 py-2.5 text-right tabular-nums text-zinc-500\">Fully received</td>\n        <td class=\"hidden px-4 py-2.5 text-right tabular-nums text-zinc-500 md:table-cell\">—</td>\n      </tr>\n    </tbody>\n  </table>\n\n  <div class=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-zinc-200 bg-zinc-100 px-4 py-2.5\">\n    <span class=\"text-[13px]/5 tabular-nums\">Raising GRN against <span class=\"font-medium\" x-text=\"po\"></span></span>\n    <button type=\"button\" class=\"shrink-0 rounded-lg bg-zinc-700 px-3 py-1.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Continue</button>\n  </div>\n</div>"
        },
        {
          "id": "states",
          "name": "Default, locked and invalid",
          "html": "<div class=\"max-w-xl space-y-6\">\n  <!-- A checked default is also the tab target, and it is what a bare POST\n       carries for everybody who never read the question. That is right for the\n       ordinary case and wrong for a consequential one. -->\n  <fieldset>\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">Freight</legend>\n    <div class=\"space-y-2\">\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" name=\"freight\" value=\"for\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>FOR destination — included in the rate</span>\n      </label>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" name=\"freight\" value=\"exworks\" class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Ex works — we arrange the vehicle</span>\n      </label>\n      <!-- disabled: out of the Tab order, skipped by the arrow keys, and out of\n           the POST. It keeps its place and says why. -->\n      <div>\n        <label class=\"flex items-start gap-2.5 text-[14px]/5 text-zinc-500\">\n          <input type=\"radio\" name=\"freight\" value=\"topay\" disabled class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n          <span>To pay</span>\n        </label>\n        <p class=\"mt-1 pl-[26px] text-[12px]/4 text-zinc-500\">Not available on this rate contract.</p>\n      </div>\n    </div>\n  </fieldset>\n\n  <!-- Settled, and not editable here. Disabling the group would be worse than\n       useless: disabled options submit nothing, so the stored value is cleared\n       on the next save. The answer is rendered, and travels in a hidden input. -->\n  <div>\n    <p class=\"text-[13px]/5 font-medium text-zinc-600\">Inspection</p>\n    <p class=\"mt-1 flex items-center gap-2 text-[14px]/5\">\n      <i data-lucide=\"check\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      At vendor works — fixed when the order was released\n    </p>\n    <input type=\"hidden\" name=\"inspection\" value=\"vendor\">\n  </div>\n\n  <!-- Unanswered and required. required goes on every option, because only the\n       focused one's attribute is announced, and the error is referenced from\n       every option for the same reason. The asterisk is decoration. -->\n  <fieldset>\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">QC result for GRN-24-0912 <span class=\"text-red-600\">*</span></legend>\n    <div class=\"space-y-2\">\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" name=\"qc\" value=\"pass\" required aria-invalid=\"true\" aria-describedby=\"rd-qc-err\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Passed — release to stores</span>\n      </label>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" name=\"qc\" value=\"deviation\" required aria-invalid=\"true\" aria-describedby=\"rd-qc-err\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Passed on deviation — needs a plant head note</span>\n      </label>\n      <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n        <input type=\"radio\" name=\"qc\" value=\"reject\" required aria-invalid=\"true\" aria-describedby=\"rd-qc-err\"\n               class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n        <span>Rejected — raise a debit note</span>\n      </label>\n    </div>\n    <p id=\"rd-qc-err\" class=\"mt-2 flex items-start gap-1.5 text-[12px]/4 font-medium text-red-600\">\n      <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>\n      Pick a result. An unanswered group posts nothing at all, so the GRN cannot be saved half-decided.\n    </p>\n  </fieldset>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form field",
          "html": "<!-- forms.py\n     class GrnForm(forms.Form):\n         QC = [('pass', 'Passed — release to stores'),\n               ('deviation', 'Passed on deviation — needs a plant head note'),\n               ('reject', 'Rejected — raise a debit note')]\n\n         # ChoiceField is required=True by default, which is usually right here:\n         # an unanswered group posts no key at all, value_from_datadict reads it\n         # as None and the form comes back with \"This field is required\".\n         #\n         # Leave initial off for a consequential choice. An initial= is checked\n         # on first render, which makes it the tab target and makes it the answer\n         # for everybody who submits without reading the question.\n         qc = forms.ChoiceField(\n             choices=QC, label='QC result',\n             widget=forms.RadioSelect(attrs={\n                 'class': 'mt-0.5 size-4 shrink-0 accent-zinc-700',\n             }))\n\n         # \"Not required\" is an option with a value of its own. A radio cannot be\n         # unticked, so required=False buys nothing on its own — it only means\n         # the form tolerates the empty POST that a group nobody touched sends.\n         # With an option checked by default the group always posts, and \"no\n         # inspection\" arrives as a decision rather than as silence.\n         INSPECTION = [('none', 'Not required'),\n                       ('gate', 'At the plant gate'),\n                       ('third_party', 'Third-party agency')]\n\n         inspection = forms.ChoiceField(\n             choices=INSPECTION, initial='none', label='Inspection',\n             widget=forms.RadioSelect(attrs={\n                 'class': 'mt-0.5 size-4 shrink-0 accent-zinc-700',\n             }))\n\n     Loop the bound field rather than printing {{ form.qc }}. RadioSelect brings\n     its own wrapper — a <div> per option since Django 4.0, a <ul><li> before\n     that — and attrs will not lay it out: they are inherited into every option\n     and also copied onto that wrapping div, so a size-4 class sizes the\n     container as well as the inputs.\n\n     Each iteration yields {{ choice.tag }} and {{ choice.choice_label }}, and\n     {{ choice.id_for_label }} is that option's own id. On the field itself\n     RadioSelect.id_for_label returns an empty string on purpose — a label\n     pointing at the group would toggle the first option — so the question is a\n     <legend>, never a <label for>.\n\n     required is inherited into every option, which is what the browser needs to\n     block the submit and what a screen reader needs to announce on whichever\n     option has focus. -->\n<form method=\"post\" class=\"max-w-xl\">\n  {% csrf_token %}\n\n  <fieldset>\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">\n      {{ form.qc.label }}{% if form.qc.field.required %} <span class=\"text-red-600\">*</span>{% endif %}\n    </legend>\n    <div class=\"space-y-2\">\n      {% for choice in form.qc %}\n        <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n          {{ choice.tag }}<span>{{ choice.choice_label }}</span>\n        </label>\n      {% endfor %}\n    </div>\n    {% if form.qc.errors %}\n      <p id=\"qc-err\" class=\"mt-2 flex items-start gap-1.5 text-[12px]/4 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>{{ form.qc.errors.0 }}\n      </p>\n    {% endif %}\n  </fieldset>\n\n  <fieldset class=\"mt-6\">\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">{{ form.inspection.label }}</legend>\n    <div class=\"space-y-2\">\n      {% for choice in form.inspection %}\n        <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n          {{ choice.tag }}<span>{{ choice.choice_label }}</span>\n        </label>\n      {% endfor %}\n    </div>\n  </fieldset>\n\n  <!-- A register picks one record with one name across every row, read back\n       with request.POST.get('grn_against') — .getlist() is the checkbox idiom\n       and returns a one-item list here. Render this list once: a second copy\n       for a phone layout shares the name, so the two are a single group and the\n       checked option in the display:none copy is the one that posts. -->\n  <fieldset class=\"mt-6\">\n    <legend class=\"mb-2 text-[13px]/5 font-medium\">Raise the GRN against</legend>\n    <div class=\"space-y-2\">\n      {% for order in orders %}\n        <label class=\"flex items-center gap-2.5 text-[13px]/5\">\n          <input type=\"radio\" name=\"grn_against\" value=\"{{ order.number }}\"\n                 {% if order.number == chosen %}checked{% endif %}\n                 {% if order.fully_received %}disabled{% endif %}\n                 required class=\"size-4 shrink-0 accent-zinc-700\">\n          <span class=\"min-w-0 flex-1 truncate tabular-nums\">{{ order.number }} · {{ order.vendor }}</span>\n          <span class=\"shrink-0 tabular-nums text-zinc-600\">{{ order.pending }}</span>\n        </label>\n      {% endfor %}\n    </div>\n  </fieldset>\n\n  <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Post GRN</button>\n</form>"
        }
      ]
    },
    {
      "id": "toggle",
      "name": "Toggle",
      "category": "forms",
      "description": "A setting that takes effect the moment it is touched. A real checkbox carrying role=\"switch\", with the track and the thumb beside it painted by peer-checked utilities.",
      "when_to_use": "A preference that applies at once and is undone just as fast: notifications on a vendor account, a filter over a register, a flag on a rate contract. If the change only lands when a Save button is pressed it is a checkbox, and if it has to be confirmed before it happens it is a button.",
      "rules": [
        "Every switch here is a real <input type=\"checkbox\"> with role=\"switch\", hidden with sr-only peer, and the track and thumb are siblings painted by peer-checked. A <button role=\"switch\"> is the other legal shape and it is the wrong one for a setting: it does not submit, it does not bind to a Django BooleanField, and it makes you write aria-checked and keep it in step by hand. The input gets the space bar, the label, form participation and the checked-to-aria-checked mapping for nothing.",
        "Never write aria-checked on the input. role=\"switch\" on a native checkbox already maps the checked property onto it, and an attribute written once in markup does not move when the control does — which is how a switch ends up announced as off for the rest of the session. aria-checked is required only on the button form, and forbidden here.",
        "peer reaches siblings, not descendants. The thumb lives inside the track, so its travel is written on the track as peer-checked:[&>span]:translate-x-4. The obvious fix — wrapping input, track and thumb in a span so all three are siblings — moves the input one level down and silently drops the pointer, because the base cursor rule matches label:has(> input[type=\"checkbox\"]). Keep the input as the label's direct child and reach the thumb through the track.",
        "The off track is a shape, so it takes a fill and a ring: bg-zinc-200 ring-1 ring-inset ring-zinc-300. A bg-zinc-100 track measures 1.00 against the zinc-100 page and vanishes, and on white it is a smudge with no edge. On is bg-zinc-700 with peer-checked:ring-zinc-700 — a solid shape needs no ring, and matching the ring to the fill is how you get that without the geometry changing between states.",
        "Focus is an outline, not a ring. The track has already spent its ring on the tinted edge, and ring-3 would replace it — a 3px inset halo eating the middle of a 20px pill at the exact moment the edge disappears. peer-focus-visible darkens the existing ring to zinc-700 and adds outline-3 outline-offset-2 outline-zinc-700/15 outside the pill, which is the same border-plus-halo the input wears, stacks with the ring, and is the one focus indicator that survives forced-colours mode.",
        "Never transition-all. The thumb takes transition-transform and the track transition-colors; nothing else moves. transition-all also picks up the focus outline, so the indicator fades in over 150ms and reads as no indicator at all to somebody tabbing through a settings list. Both carry motion-reduce:transition-none — a switch that jumps is still a switch.",
        "The state cannot be colour alone. In forced-colours mode every background is dropped, so bg-zinc-700 and bg-zinc-200 render identically; what survives is the thumb sitting at one end or the other, plus forced-colors:border on the track and the thumb to give the pill and the disc an edge. Do not put ON and OFF lettering inside the track either — it does not fit at 36×20, it does not translate, and it is the position that is doing the work.",
        "A switch inside a form with a Save button is a checkbox drawn wrong. The whole promise of the shape is that touching it is the write; put it above a Save and half the users will press Save and half will not, and the two groups get different results from the same gesture. Either the control writes on change, or it is a checkbox.",
        "No \"Are you sure?\" on a switch. A confirmation dialog says the action is worth stopping for, and anything worth stopping for is a button with a verb on it — Deactivate vendor, not a switch labelled Active. A switch is for settings whose undo is the same gesture as the do.",
        "Because it writes on change it owes an answer. htmx posts on change, the browser has already painted the new position, so the only work left is the undo: revert checked on a failed request and say so in the row. hx-sync=\"this:replace\" aborts a write still in flight so two toggles inside a second cannot land out of order. A switch that silently lost the write is worse than a slow one, because the screen now disagrees with the database and nothing on it says so.",
        "The label names the setting; the switch carries the state. \"Enabled\" beside a switch that is off is a contradiction read aloud — announced, it comes out \"Enabled, switch, off\". Write what the setting does — \"Email the buyer when an order is approved\" — and let the position say whether it is happening.",
        "There is no mixed state. A checkbox has indeterminate; a switch has two positions and nothing to draw a third with, so a master switch over a group is not a select-all — it is a setting of its own (\"send email at all\") and the dependants keep their values while it is off. Clear them instead and turning the master back on hands the user a blank slate they have to rebuild from memory."
      ],
      "anatomy": [
        {
          "part": "Track",
          "description": "The 36×20 pill: relative h-5 w-9, bg-zinc-200 with ring-1 ring-inset ring-zinc-300 off, bg-zinc-700 with a matching ring on. It owns the focus indicator, because the input it belongs to is sr-only and paints nothing."
        },
        {
          "part": "Thumb",
          "description": "An absolutely positioned size-4 white disc at top-0.5 left-0.5, moved to translate-x-4 by the track's peer-checked:[&>span] rule. Its position is the state; the fill only agrees with it."
        },
        {
          "part": "Input",
          "description": "A real checkbox with role=\"switch\", class=\"peer sr-only\", and a name if anything is ever going to submit it. Direct child of the label, before the track, or neither the peer selectors nor the pointer work."
        },
        {
          "part": "Label",
          "description": "Wraps the input and names the setting, not its state. It is also the target: the track is 20px tall and WCAG 2.2 asks for 24, so the label is the full width of the row and carries the padding that closes the gap."
        },
        {
          "part": "Help text",
          "description": "A 12px zinc-500 line outside the label, indented past the switch with pr-13 — the track plus the gap — and pointed at with aria-describedby. Inside the label it joins the switch's accessible name and is read back in full on every toggle."
        },
        {
          "part": "Status line",
          "description": "The row's answer: last changed, Saving, Saved, or Not saved with a way to try again. role=\"status\", because the revert after a failed write is programmatic and a programmatic change to checked is announced by nothing."
        },
        {
          "part": "Row",
          "description": "Label left, switch right, one setting per row, divided by border-zinc-100 inside a bordered card. The switch is shrink-0 so a long setting name wraps rather than squeezing the pill."
        }
      ],
      "behaviour": [
        "Touching it is the write. There is no Save button on a page of switches and no dirty state to track — the change is sent on change, and the row says what happened to it.",
        "The optimistic paint is free: the track and thumb are drawn from the input's own checked state, so the switch has already moved before the request leaves. All the code does is undo it — the failure handler flips checked back and the row reports it.",
        "A failed write reverts the switch to the value the server still holds and says so in words, with a Try again that re-applies what the user actually asked for rather than re-posting the reverted value.",
        "Two toggles inside a second are one intent, not two. hx-sync=\"this:replace\" drops the request still in flight so the last position is the one the server ends on; without it the replies can land out of order and the row settles on the older one.",
        "Disabled and pending look nothing alike. Disabled drains the whole control to 60% and refuses the pointer; pending stays at full strength, still takes another toggle, and says Saving in the row. Drain a pending switch and you have told the user it is locked when it is not, and hidden which way they just set it.",
        "A master switch turns its dependants off without clearing them, so switching it back on restores the set somebody chose rather than an empty one. The dependants stay visible and disabled rather than disappearing — a list that changes length is a list nobody can scan by position.",
        "At 390px the row keeps its shape: the setting name wraps, the switch stays 36px on the right, and the help text runs under the name. Nothing scrolls sideways and nothing stacks the switch under its own label, where it stops looking like it belongs to it."
      ],
      "accessibility": [
        "role=\"switch\" on the native input is what makes it announce on and off instead of checked and unchecked. The checked property maps to aria-checked by itself, so nothing here writes aria-checked, and nothing should — an attribute set once in markup goes stale the first time the control moves.",
        "The label wraps the input and names the setting rather than its state. Help text stays outside the label and is attached with aria-describedby, or the whole explanation becomes part of the accessible name and is read back on every toggle.",
        "A switch on a record needs a name of its own: aria-label=\"Only overdue\" on a filter, aria-label=\"Active — Gujarat Polymers Ltd\" in a list. Twelve switches all called Active say nothing about which row the cursor is on.",
        "The focus indicator lives on the track, because the input is sr-only and paints nothing itself. It is an outline with an offset rather than a ring, so it stacks with the tinted edge and survives forced-colours mode, where box-shadows are dropped. Nothing here writes outline-none.",
        "The status line is a role=\"status\" region. The user hears their own toggle, but the revert after a failed write is script setting checked, and a programmatic change is announced by nothing — without the live region the only sign the setting did not stick is a pixel moving back.",
        "State never rests on colour. Forced-colours mode drops the fills and both tracks render the same, so the thumb's position carries it, with forced-colors:border on the track and the thumb so the pill and the disc still have edges.",
        "Disabled uses the disabled attribute, which takes the switch out of the Tab order, and it keeps its position so the setting is still readable. A value nobody may ever change is not a dead switch — it is text, the way a locked checkbox is."
      ],
      "related": [
        "checkbox",
        "radio",
        "field"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- A real checkbox with role=\"switch\", sr-only so nothing of it is painted,\n     and peer so the track beside it can read its state. The input is the\n     label's direct child: move it into a wrapper to make the thumb a sibling\n     and the base cursor rule, label:has(> input[type=\"checkbox\"]), stops\n     matching. peer only reaches siblings, so the thumb's travel is written on\n     the track instead, as peer-checked:[&>span]:translate-x-4.\n\n     The help text sits outside the label. Inside it, it joins the switch's\n     accessible name and is read back in full on every toggle. pr-13 is the\n     track plus the gap, so the sentence lines up under the setting name. -->\n<div class=\"max-w-xl\">\n  <label class=\"flex items-start justify-between gap-4 py-1\">\n    <span class=\"text-[14px]/5 tabular-nums\">Auto-approve orders under ₹50,000</span>\n    <input type=\"checkbox\" role=\"switch\" id=\"sw-auto\" name=\"auto_approve\" value=\"1\" checked\n           aria-describedby=\"sw-auto-help\" class=\"peer sr-only\">\n    <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n      <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n    </span>\n  </label>\n  <p id=\"sw-auto-help\" class=\"mt-1 pr-13 text-[12px]/4 text-zinc-500\">\n    Gujarat Polymers Ltd only. Applies from the next order — there is nothing to save.\n  </p>\n</div>"
        },
        {
          "id": "sizes",
          "name": "Sizes",
          "html": "<!-- Two sizes and no more. 36×20 is the settings row; 28×16 is for a toolbar\n     or a table header, where the switch sits in a line of 13px controls and the\n     full-size pill is taller than everything beside it.\n\n     Neither track is a target on its own — 20px and 16px are both under the\n     24px WCAG 2.2 asks for. The label is the target, and the padding on it is\n     what closes the gap: py-1 round the small one takes the row to 24px, and a\n     settings row is taller than that already. -->\n<div class=\"max-w-xl space-y-4\">\n  <label class=\"flex items-center justify-between gap-4 py-1\">\n    <span class=\"text-[14px]/5 tabular-nums\">Default — 36×20, one setting per row</span>\n    <input type=\"checkbox\" role=\"switch\" checked class=\"peer sr-only\">\n    <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n      <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n    </span>\n  </label>\n\n  <label class=\"flex items-center justify-between gap-3 py-1\">\n    <span class=\"text-[13px]/5 tabular-nums\">Small — 28×16, in a toolbar or a table header</span>\n    <input type=\"checkbox\" role=\"switch\" checked class=\"peer sr-only\">\n    <span class=\"relative h-4 w-7 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-3 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n      <span class=\"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n    </span>\n  </label>\n</div>"
        },
        {
          "id": "list",
          "name": "Settings list",
          "html": "<!-- A page of switches has no Save button, and the header says so once rather\n     than once per row. Each row is a label naming the setting — never its state\n     — with the explanation outside the label and pointed at by\n     aria-describedby.\n\n     One locked row keeps its place and its position. Dropping it would change\n     the shape of a list people scan by position, and repainting it to the off\n     fill would say the setting is off when it is on and out of their hands. -->\n<div class=\"max-w-xl rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"border-b border-zinc-200 px-4 py-3\">\n    <h3 class=\"text-[13px]/5 font-medium\">Notifications — Gujarat Polymers Ltd</h3>\n    <p class=\"mt-0.5 text-[12px]/4 text-zinc-500\">Each switch applies as you set it. There is nothing to save.</p>\n  </div>\n\n  <div class=\"divide-y divide-zinc-100\">\n    <div class=\"px-4 py-2.5\">\n      <label class=\"flex items-start justify-between gap-4 py-1\">\n        <span class=\"text-[14px]/5\">Email the vendor when an order is approved</span>\n        <input type=\"checkbox\" role=\"switch\" name=\"notify_approved\" value=\"1\" checked\n               aria-describedby=\"sw-approved-help\" class=\"peer sr-only\">\n        <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n          <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n        </span>\n      </label>\n      <p id=\"sw-approved-help\" class=\"mt-1 pr-13 text-[12px]/4 text-zinc-500\">Goes to the contact on the rate contract, with the order PDF attached.</p>\n    </div>\n\n    <div class=\"px-4 py-2.5\">\n      <label class=\"flex items-start justify-between gap-4 py-1\">\n        <span class=\"text-[14px]/5\">Email the buyer when a GRN is posted</span>\n        <input type=\"checkbox\" role=\"switch\" name=\"notify_grn\" value=\"1\" checked\n               aria-describedby=\"sw-grn-help\" class=\"peer sr-only\">\n        <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n          <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n        </span>\n      </label>\n      <p id=\"sw-grn-help\" class=\"mt-1 pr-13 text-[12px]/4 text-zinc-500\">Short receipts are flagged in the same mail.</p>\n    </div>\n\n    <div class=\"px-4 py-2.5\">\n      <label class=\"flex items-start justify-between gap-4 py-1\">\n        <span class=\"text-[14px]/5 tabular-nums\">Warn 30 days before the rate contract expires</span>\n        <input type=\"checkbox\" role=\"switch\" name=\"notify_contract\" value=\"1\"\n               aria-describedby=\"sw-contract-help\" class=\"peer sr-only\">\n        <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n          <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n        </span>\n      </label>\n      <p id=\"sw-contract-help\" class=\"mt-1 pr-13 text-[12px]/4 tabular-nums text-zinc-500\">Current contract runs to 31 Mar 2027.</p>\n    </div>\n\n    <div class=\"px-4 py-2.5\">\n      <label class=\"flex items-start justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n        <span class=\"text-[14px]/5\">Three-way match against the invoice</span>\n        <input type=\"checkbox\" role=\"switch\" checked disabled\n               aria-describedby=\"sw-match-help\" class=\"peer sr-only\">\n        <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n          <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n        </span>\n      </label>\n      <p id=\"sw-match-help\" class=\"mt-1 pr-13 text-[12px]/4 tabular-nums text-zinc-500\">Compulsory above ₹10,00,000. It changes on the rate contract, not here.</p>\n    </div>\n  </div>\n\n  <div class=\"border-t border-zinc-200 px-4 py-2.5\">\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\">Last changed 16 Aug 2026, 11:04 by Ritu Deshpande</p>\n  </div>\n</div>"
        },
        {
          "id": "group",
          "name": "Master and dependants",
          "html": "<!-- A switch has no third position, so a master over a group is not a\n     select-all and never reads as mixed. It is a setting of its own — send\n     email at all — and the dependants keep their values while it is off, so\n     switching it back on restores the set somebody chose rather than an empty\n     one.\n\n     The dependants stay on screen and go disabled rather than disappearing: a\n     list that changes length under the pointer is a list nobody can scan by\n     position. peer-disabled:opacity-60 drains the whole switch in one go, which\n     is why the drain sits on the track and not on the thumb inside it. -->\n<fieldset class=\"max-w-xl rounded-xl border border-zinc-200 bg-white\" x-data=\"{ email: true }\">\n  <legend class=\"sr-only\">Email notifications for Gujarat Polymers Ltd</legend>\n\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-start justify-between gap-4 py-1\">\n      <span class=\"text-[14px]/5 font-medium\">Email notifications</span>\n      <input type=\"checkbox\" role=\"switch\" x-model=\"email\"\n             aria-describedby=\"sw-master-help\" class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n    <p id=\"sw-master-help\" class=\"mt-1 pr-13 text-[12px]/4 text-zinc-500\">\n      Off stops every mail below without forgetting which of them were on.\n    </p>\n  </div>\n\n  <div class=\"space-y-3.5 border-t border-zinc-100 px-4 py-3.5\">\n    <label class=\"flex items-center justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n      <span class=\"text-[13px]/5\">Order approved</span>\n      <input type=\"checkbox\" role=\"switch\" checked :disabled=\"!email\" class=\"peer sr-only\">\n      <span class=\"relative h-4 w-7 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-3 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n\n    <label class=\"flex items-center justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n      <span class=\"text-[13px]/5\">GRN posted against an order</span>\n      <input type=\"checkbox\" role=\"switch\" checked :disabled=\"!email\" class=\"peer sr-only\">\n      <span class=\"relative h-4 w-7 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-3 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n\n    <label class=\"flex items-center justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n      <span class=\"text-[13px]/5 tabular-nums\">Payment due inside 3 days</span>\n      <input type=\"checkbox\" role=\"switch\" :disabled=\"!email\" class=\"peer sr-only\">\n      <span class=\"relative h-4 w-7 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-3 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n  </div>\n</fieldset>"
        },
        {
          "id": "inline",
          "name": "In a toolbar",
          "html": "<!-- A filter is the honest inline switch: it applies the moment it is touched,\n     the register below re-runs, and the count is the confirmation. A filter\n     that only takes effect on an Apply button is a checkbox, and drawing it as\n     a switch promises a write that never happens.\n\n     The small track is 16px tall, so the label carries py-1.5 to bring the\n     target to 28px. aria-label is not needed here — the text beside it is the\n     label, and it names the filter rather than its state. -->\n<div class=\"flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-zinc-200 bg-white px-4 py-2.5\">\n  <h3 class=\"text-[13px]/5 font-medium\">Purchase orders</h3>\n  <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">1,438 open · 84 overdue</span>\n\n  <label class=\"ml-auto inline-flex items-center gap-2.5 py-1.5 text-[13px]/5\">\n    <span>Only overdue</span>\n    <input type=\"checkbox\" role=\"switch\" name=\"overdue\" value=\"1\"\n           hx-get=\"/orders/\" hx-trigger=\"change\" hx-target=\"#po-rows\" hx-swap=\"innerHTML\"\n           hx-push-url=\"true\" class=\"peer sr-only\">\n    <span class=\"relative h-4 w-7 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-3 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n      <span class=\"absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n    </span>\n  </label>\n</div>\n\n<div id=\"po-rows\" class=\"mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3\">\n  <p class=\"text-[13px]/5 tabular-nums text-zinc-600\">PO-24-1187 · Gujarat Polymers Ltd · ₹18,42,000</p>\n</div>"
        },
        {
          "id": "states",
          "name": "On, off, locked, saving, failed",
          "html": "<!-- Five rows, and the point of the last two is that they look nothing alike.\n     Disabled drains the whole control to 60% and refuses the pointer, because\n     nobody may move it. Pending stays at full strength and still takes another\n     toggle, because it is a write in flight, not a locked control — drain it\n     and you have told the user it is locked when it is not, and hidden which\n     way they just set it.\n\n     The locked row is on and stays on. Repainting a locked switch to the off\n     fill would say the setting is off when it is on and out of their hands.\n\n     The handlers behind Saving and Not saved are in the htmx variant; here the\n     rows are pinned so both states can be read side by side. -->\n<div class=\"max-w-xl divide-y divide-zinc-100 rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-center justify-between gap-4 py-1\">\n      <span class=\"text-[14px]/5\">On — the setting is in force</span>\n      <input type=\"checkbox\" role=\"switch\" checked class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n  </div>\n\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-center justify-between gap-4 py-1\">\n      <span class=\"text-[14px]/5\">Off — a shape with a fill and a ring, not a gap</span>\n      <input type=\"checkbox\" role=\"switch\" class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n  </div>\n\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-start justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n      <span class=\"text-[14px]/5\">Locked — on, and not yours to change</span>\n      <input type=\"checkbox\" role=\"switch\" checked disabled\n             aria-describedby=\"sw-locked-help\" class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n    <p id=\"sw-locked-help\" class=\"mt-1 pr-13 text-[12px]/4 tabular-nums text-zinc-500\">Set by plant policy above ₹10,00,000.</p>\n  </div>\n\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-start justify-between gap-4 py-1\">\n      <span class=\"text-[14px]/5\">Saving — moved, and the write is still out</span>\n      <input type=\"checkbox\" role=\"switch\" checked aria-busy=\"true\" class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n    <p role=\"status\" class=\"mt-1 flex items-center gap-1.5 pr-13 text-[12px]/4 text-zinc-500\">\n      <i data-lucide=\"loader-circle\" class=\"size-3.5 shrink-0 animate-spin\"></i>Saving\n    </p>\n  </div>\n\n  <div class=\"px-4 py-2.5\">\n    <label class=\"flex items-start justify-between gap-4 py-1\">\n      <span class=\"text-[14px]/5\">Failed — reverted, and said so</span>\n      <input type=\"checkbox\" role=\"switch\" class=\"peer sr-only\">\n      <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n        <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n      </span>\n    </label>\n    <p role=\"status\" class=\"mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pr-13 text-[12px]/4 font-medium text-red-600\">\n      <span class=\"flex items-center gap-1.5\"><i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>Not saved — the setting is unchanged</span>\n      <button type=\"button\" class=\"font-normal text-zinc-900 underline underline-offset-2\">Try again</button>\n    </p>\n  </div>\n</div>"
        },
        {
          "id": "htmx",
          "name": "Saved on change",
          "html": "<!-- htmx does the writing, because Alpine does not fetch. The optimistic paint\n     is free: the track reads the input's own checked state, so the switch has\n     already moved before the request leaves. The only code here is the undo.\n\n     want is captured before the request, so Try again re-applies what the user\n     asked for rather than re-posting the value the failure reverted to.\n\n     hx-sync=\"this:replace\" drops a write still in flight, so two toggles inside\n     a second cannot land out of order and settle on the older reply.\n     hx-swap=\"none\" because the row already looks the way it should; the\n     response only has to say whether it worked.\n\n     One endpoint per setting is what makes the POST readable. An unticked\n     checkbox posts nothing at all, so a body carrying only this field says off\n     by its absence — the same absence inside a twelve-field form cannot be told\n     from a field that was never rendered. Django also needs the CSRF token on\n     the request; that is in the django variant.\n\n     The status line is role=\"status\" because the revert is script setting\n     checked, and a programmatic change is announced by nothing. Without it the\n     only sign the setting did not stick is a pixel moving back. -->\n<div class=\"max-w-xl rounded-xl border border-zinc-200 bg-white px-4 py-2.5\"\n     x-data=\"{ state: 'idle', want: true }\"\n     @htmx:before-request.camel=\"want = $refs.sw.checked; state = 'saving'\"\n     @htmx:after-request.camel=\"\n       if ($event.detail.successful) { state = 'saved' }\n       else { $refs.sw.checked = !want; state = 'failed' }\">\n\n  <label class=\"flex items-start justify-between gap-4 py-1\">\n    <span class=\"text-[14px]/5 tabular-nums\">Auto-approve orders under ₹50,000</span>\n    <input type=\"checkbox\" role=\"switch\" x-ref=\"sw\" name=\"on\" value=\"1\" checked\n           hx-post=\"/vendors/gujarat-polymers/settings/auto-approve/\"\n           hx-trigger=\"change\" hx-swap=\"none\" hx-sync=\"this:replace\"\n           class=\"peer sr-only\">\n    <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n      <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n    </span>\n  </label>\n\n  <p role=\"status\" class=\"mt-1 pr-13 text-[12px]/4\">\n    <span x-show=\"state === 'idle'\" class=\"tabular-nums text-zinc-500\">Last changed 16 Aug 2026, 11:04 by Ritu Deshpande</span>\n\n    <span x-show=\"state === 'saving'\" x-cloak class=\"flex items-center gap-1.5 text-zinc-500\">\n      <i data-lucide=\"loader-circle\" class=\"size-3.5 shrink-0 animate-spin\"></i>Saving\n    </span>\n\n    <span x-show=\"state === 'saved'\" x-cloak class=\"flex items-center gap-1.5 text-zinc-600\">\n      <i data-lucide=\"check\" class=\"size-3.5 shrink-0 text-emerald-600\"></i>Saved\n    </span>\n\n    <span x-show=\"state === 'failed'\" x-cloak class=\"flex flex-wrap items-center gap-x-2 gap-y-1 font-medium text-red-600\">\n      <span class=\"flex items-center gap-1.5\">\n        <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>Not saved — the setting is unchanged\n      </span>\n      <button type=\"button\" class=\"font-normal text-zinc-900 underline underline-offset-2\"\n              @click=\"$refs.sw.checked = want; $refs.sw.dispatchEvent(new Event('change'))\">Try again</button>\n    </span>\n  </p>\n</div>"
        },
        {
          "id": "django",
          "name": "Django settings endpoint",
          "html": "<!-- forms.py\n     class SettingForm(forms.Form):\n         # required=False, always. On a BooleanField required=True means \"must\n         # be ticked\", so a flag that is allowed to be off comes back invalid\n         # with \"This field is required\" the first time somebody switches it off.\n         on = forms.BooleanField(required=False)\n\n     urls.py\n         path('vendors/<slug:slug>/settings/<slug:key>/', views.vendor_setting,\n              name='vendor-setting'),\n\n     views.py\n         @require_POST\n         def vendor_setting(request, slug, key):\n             vendor = get_object_or_404(Vendor, slug=slug)\n             if key not in EDITABLE_SETTINGS:          # never setattr a POST key\n                 return HttpResponseBadRequest()\n             form = SettingForm(request.POST)\n             form.is_valid()\n             setattr(vendor, key, form.cleaned_data['on'])\n             vendor.save(update_fields=[key])\n             # 204 leaves the row alone: the browser painted the new position\n             # before the request left. Anything 4xx or 5xx and the row's\n             # after-request handler puts the switch back where it was.\n             return HttpResponse(status=204)\n\n     One endpoint per setting is what makes the absence readable: an unticked\n     box posts nothing at all, and a body carrying only \"on\" says off by\n     omission. There is deliberately no <form> and no Save button here — a\n     switch above a Save is a checkbox that has been drawn wrong, and half the\n     users will press Save and half will not.\n\n     {{ form.on }} would render the bare input and you would still be writing\n     the track and thumb around it, so the widget only exists to carry the\n     attributes:\n\n         widget=forms.CheckboxInput(attrs={\n             'role': 'switch', 'class': 'peer sr-only',\n             'hx-trigger': 'change', 'hx-swap': 'none', 'hx-sync': 'this:replace',\n         })\n\n     hx-headers sits on the card so every switch inside it sends the CSRF token;\n     htmx will not find one without a form to read it from. -->\n<div class=\"max-w-xl divide-y divide-zinc-100 rounded-xl border border-zinc-200 bg-white\"\n     hx-headers='{\"X-CSRFToken\": \"{{ csrf_token }}\"}'>\n  {% for setting in settings %}\n    <div class=\"px-4 py-2.5\"\n         x-data=\"{ state: 'idle', want: false }\"\n         @htmx:before-request.camel=\"want = $refs.sw.checked; state = 'saving'\"\n         @htmx:after-request.camel=\"\n           if ($event.detail.successful) { state = 'saved' }\n           else { $refs.sw.checked = !want; state = 'failed' }\">\n\n      <label class=\"flex items-start justify-between gap-4 py-1 has-[:disabled]:text-zinc-500\">\n        <span class=\"text-[14px]/5 tabular-nums\">{{ setting.label }}</span>\n        <input type=\"checkbox\" role=\"switch\" x-ref=\"sw\" name=\"on\" value=\"1\"\n               {% if setting.on %}checked{% endif %}{% if setting.locked %} disabled{% endif %}\n               aria-describedby=\"sw-{{ setting.key }}-help\"\n               hx-post=\"{% url 'vendor-setting' vendor.slug setting.key %}\"\n               hx-trigger=\"change\" hx-swap=\"none\" hx-sync=\"this:replace\"\n               class=\"peer sr-only\">\n        <span class=\"relative h-5 w-9 shrink-0 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 transition-colors peer-checked:bg-zinc-700 peer-checked:ring-zinc-700 peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-zinc-700 peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-zinc-700/15 peer-disabled:opacity-60 motion-reduce:transition-none forced-colors:border\">\n          <span class=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none forced-colors:border\"></span>\n        </span>\n      </label>\n\n      <p id=\"sw-{{ setting.key }}-help\" class=\"mt-1 pr-13 text-[12px]/4 tabular-nums text-zinc-500\">{{ setting.help }}</p>\n\n      <p role=\"status\" class=\"pr-13 text-[12px]/4\">\n        <span x-show=\"state === 'saving'\" x-cloak class=\"mt-1 flex items-center gap-1.5 text-zinc-500\">\n          <i data-lucide=\"loader-circle\" class=\"size-3.5 shrink-0 animate-spin\"></i>Saving\n        </span>\n        <span x-show=\"state === 'saved'\" x-cloak class=\"mt-1 flex items-center gap-1.5 text-zinc-600\">\n          <i data-lucide=\"check\" class=\"size-3.5 shrink-0 text-emerald-600\"></i>Saved\n        </span>\n        <span x-show=\"state === 'failed'\" x-cloak class=\"mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-medium text-red-600\">\n          <span class=\"flex items-center gap-1.5\">\n            <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>Not saved — the setting is unchanged\n          </span>\n          <button type=\"button\" class=\"font-normal text-zinc-900 underline underline-offset-2\"\n                  @click=\"$refs.sw.checked = want; $refs.sw.dispatchEvent(new Event('change'))\">Try again</button>\n        </span>\n      </p>\n    </div>\n  {% endfor %}\n</div>"
        }
      ]
    },
    {
      "id": "combobox",
      "name": "Combobox",
      "category": "forms",
      "description": "A text box that filters a list of records and commits one of them. The typing is a search; what reaches the server is a hidden input under it.",
      "when_to_use": "A field whose answer is one record out of more than about fifteen — a vendor, a cost centre, an item code. Below fifteen a native select costs no JavaScript and behaves correctly on a phone. For moving around the application rather than filling in a field, use the command palette: a palette navigates, a combobox posts.",
      "rules": [
        "The visible box carries no name. It is a search field, not the value: name it vendor and the half-typed \"guj\" posts beside the vendor id, and request.POST['vendor'] is whichever of the two the browser serialised last. One hidden input under the field is what submits, and in a multiselect it is one hidden input per value all sharing the name, read back with request.POST.getlist.",
        "Closing without committing restores the query to the label of the value still selected. Without that somebody types \"nash\", clicks away, and the field reads Nashik over a hidden value of gujarat-polymers — a field lying about itself, which no server validation catches because the POST is internally consistent.",
        "aria-activedescendant needs a real id on a real node. Inside template x-for the id is derived from the option — :id=\"'cb-vendor-' + o.id\" — never from the loop index, because filtering renumbers the rows and the attribute then names whichever record moved into that slot. Key the loop on the same field or Alpine reuses nodes and the id and the row drift apart. The prefix belongs to the field, so a second combobox on the page needs a second prefix or both write the same ids.",
        "The popup is x-show, never x-if. aria-activedescendant may only reference a node that exists; under x-if the listbox is not in the document until the first open, and the attribute dangles for anything that reads the field before then.",
        "Never close on @blur. Blur fires on mousedown, before the click on the option is dispatched, so the row is hidden out from under the pointer and nothing is selectable by mouse at all — the defect reads as \"clicking an option does nothing\". Close on @click.outside on the root, which runs after the click has landed, and put @mousedown.prevent on the row so focus never leaves the input in the first place.",
        "aria-selected marks what is committed, not what the arrow keys are on. Bind it to the selection and let aria-activedescendant carry the highlight. Bound to the active index instead, every row arrowed past is announced as selected, which in a multiselect cannot be told apart from having actually ticked it.",
        "Alpine keeps aria-expanded and aria-selected when they are false and removes aria-activedescendant when it is null, which is what ARIA wants in both cases. So :aria-expanded=\"open\" is correct and :aria-expanded=\"open ? 'true' : 'false'\" is noise, and a getter returning null for the active id removes the attribute rather than leaving aria-activedescendant=\"\" pointing at nothing.",
        "Escape stops propagating only while the list is open. Unconditional, it swallows the keystroke the surrounding modal is waiting for and the dialog can no longer be dismissed from inside the field. First Escape closes the list, second Escape reaches the dialog.",
        "Enter is prevented only while the list is open. An unconditional .prevent takes form submission away from the keyboard, and a two-field form whose first field is a combobox can then only be submitted by finding the button with a mouse.",
        "Arrow keys clamp at both ends, they do not wrap. Wrapping hides the fact that the list ended, and with a create row pinned last it lands on \"Add new vendor\" every time somebody overshoots the bottom. Home and End jump to the ends of the list while it is open and belong to the caret while it is closed, so they are prevented conditionally too.",
        "Focus alone does not open the list. Opening on @focus means tabbing through a form pops a listbox open at every combobox in it, and on a phone it fires as the keyboard slides up. Click, arrow keys and typing open it.",
        "The active row is scrolled into view with scrollIntoView({ block: 'nearest' }) inside $nextTick. Plain scrollIntoView() scrolls the page as well as the list and drags the whole document under the field; behavior: 'smooth' animates one row at a time and falls behind a held-down arrow key until the highlight is somewhere nobody can see.",
        "Zero matches renders an empty state naming the query, not an empty box. A popup that opens onto nothing reads as a component that broke rather than a search that found nothing.",
        "The result count is a role=\"status\" that is in the document from first paint, outside the popup. A live region inserted with its text already in it announces nothing — the region has to exist before the content changes — so a count rendered inside the x-show popup is silent on the one keystroke that mattered.",
        "Disabling a combobox has to reach the hidden input. disabled on the search box only stops the typing; the hidden input has no appearance of its own and posts regardless, so a field somebody was told they could not change still submits its value. There is no read-only combobox either: readonly leaves the box focusable, the chevron still opens the list, and readonly means nothing at all on an option row. A value nobody may change is rendered as text with a hidden input beside it.",
        "A disabled option is aria-disabled, and aria-disabled blocks nothing. The arrow keys have to step over it and the click handler has to return early, or the row is unreachable by keyboard and fully clickable by mouse. It keeps its place in the list rather than disappearing, so a locked vendor does not change the shape of a set people scan by position.",
        "Select all takes what the filter is showing and the button says the number: \"Select all 4 matching\" with a query typed, \"Select all 9\" without one. A bulk control that quietly reaches past the rows on screen is the select-all-approves-4,312-orders problem again — people can only verify what they can see. Clear is deliberately not scoped the same way: it releases everything and its label carries the total, because a Clear that only released the matching rows would leave \"3 selected\" standing over a list with nothing ticked in it and no way back to the other three.",
        "A multiselect field has to show what is in it. A count alone — 3 / 9 in the corner of the box — is a selection nobody can see without opening the popup first, and it reads as a control that did not register the click. Chips in the field are the answer, and select-all is the one control that can fill the field with them, so it shows one name and collapses everything past it into a \"+8 more\" pill that opens the list. Two chips is one too many: at 390px the field is 300px wide, two names and the search input do not fit on one line, and the box grew a row at exactly two selections and shrank again at three. The cap hides nothing from the keyboard: every option, listed or not, is a row in the popup with its own tick, and toggling it there is what releases it.",
        "The search input inside a chip field needs a small minimum and a placeholder it never drops. flex-1 min-w-24 reserves 96px that the chips cannot leave room for, so the input is pushed onto a line of its own — and with the placeholder blanked out because there are chips to look at instead, that line renders as an empty row inside the box and reads as a rendering fault rather than a search field. min-w-16 plus a placeholder that never blanks is what keeps it a search field wherever it lands.",
        "The popup is left-0 right-0 under the field, never a fixed width. w-96 on a field inside a 390px viewport hangs off the right edge with nothing to scroll it back. An ancestor carrying overflow-hidden clips the panel instead — a card, a table wrapper — so the field needs position: relative and no clipping ancestor between it and the popup.",
        "Nothing inside role=\"option\" may be focusable, so the tick on a multiselect row is a drawn square and not a real checkbox. A real box there is a tab stop inside a widget that is meant to have none, and if it carries a name it posts the value a second time beside the hidden input.",
        "Lucide icons inside template x-for do not exist when createIcons() first runs, because Alpine renders the rows after DOMContentLoaded. The page needs the guarded re-hydration loop — a MutationObserver calling createIcons() only while document.querySelector(\"[data-lucide]:not(svg)\") finds something — or the ticks and the chip crosses come up empty. Bind x-show on a wrapping span, never on the <i>.",
        "htmx's trigger filter binds to the event name, not to the end of the spec: input[this.value.length > 1] changed delay:300ms. Written as input changed delay:300ms[…] it is parsed as part of the modifier and the floor never applies. And below that floor nothing fires at all, so deleting back to one character has to clear the rows in script or last search's answer stays on screen under a query that no longer produced it.",
        "In Django the multiselect field needs a widget whose value_from_datadict calls getlist — forms.MultipleHiddenInput or forms.SelectMultiple. A ModelMultipleChoiceField handed a plain HiddenInput reads the POST with .get(), which returns the last hidden input and drops every other vendor the user picked. Seed the option list with json_script and never interpolate a queryset into an x-data attribute: one vendor called M/s D'Souza Traders ends the attribute early and the component stops parsing."
      ],
      "anatomy": [
        {
          "part": "Field",
          "description": "The bordered box. It owns the focus ring through focus-within, as the input does, so the icon, the chips and the clear button sit inside the ring."
        },
        {
          "part": "Search input",
          "description": "role=\"combobox\" with aria-autocomplete=\"list\", aria-controls, aria-expanded and aria-activedescendant. It has no name — it is the search, not the value."
        },
        {
          "part": "Hidden input",
          "description": "What actually posts. One for a single select; one per value sharing a name for a multiselect."
        },
        {
          "part": "Popup",
          "description": "An absolutely positioned panel pinned to both edges of the field, x-show and x-cloak, holding a scrolling listbox."
        },
        {
          "part": "Option",
          "description": "role=\"option\" with a stable id and aria-selected. The active row is tinted bg-zinc-100 and named by aria-activedescendant, which is a different fact from being selected."
        },
        {
          "part": "Chip",
          "description": "A committed value in a multiselect field: the graphite pill with its ring, a truncating label, and a remove button whose accessible name says which vendor it removes."
        },
        {
          "part": "Empty state",
          "description": "What the popup shows at zero matches — the query quoted back and the way out of it."
        },
        {
          "part": "Live region",
          "description": "A sr-only role=\"status\" outside the popup, in the document from first paint, carrying the number of matches."
        }
      ],
      "behaviour": [
        "Typing filters, and the first keystroke moves the highlight to the first match, so Enter always takes the row at the top of the list.",
        "Opening does not filter to the value already in the box. The query counts as a filter only once the user has typed, or a committed vendor reopens to a list of exactly one row.",
        "Arrow down and up move the highlight and clamp at the ends. Enter commits, Escape closes without committing and leaves focus in the input, Tab closes and moves on.",
        "The highlight follows the mouse as well as the keyboard, so the row under the pointer and the row Enter would take are never two different rows.",
        "A single select closes on commit and writes the label back into the box. A multiselect stays open, clears the query, and leaves the highlight on the row just toggled.",
        "Backspace on an empty query removes the last chip. It fires only when the query is empty, so it never eats a character somebody was still deleting.",
        "Select all applies to the rows the filter is showing and says how many that is; Clear releases everything and says how many that is.",
        "Selected values are chips in the field, not a number beside it. Where a bulk control can select nine at once the field shows one name and collapses everything past it into a pill, so the box holds a single row at every count and every width rather than growing one when the pill appears. The popup is where the whole selection is legible and every row can be toggled off.",
        "Remote options are fetched by htmx on a debounced input with a two-character floor. Alpine never fetches: it owns the open state and the keyboard, and because htmx replaced the rows without telling Alpine, the keyboard reads the option elements out of the DOM rather than out of an array.",
        "A value that is not in the list is offered as the last row whenever there is a query and no exact match, and it posts as text in a second field rather than as an invented id."
      ],
      "accessibility": [
        "The input is role=\"combobox\" with aria-expanded, aria-controls naming the listbox, aria-autocomplete=\"list\" and aria-activedescendant naming the active option.",
        "The popup is role=\"listbox\" with an accessible name, every row is role=\"option\" with aria-selected, and a multiselect listbox carries aria-multiselectable=\"true\".",
        "Focus never leaves the input. The highlight moves through aria-activedescendant, which is why every option needs a stable id and why no row is a tab stop.",
        "The number of matches is announced from a role=\"status\" that was in the document before the count changed.",
        "Every chip remove button names its own option — \"Remove Nashik Steel Traders\" — because twelve buttons all called Remove say nothing about which one the cursor is on.",
        "Grouped options sit in role=\"group\" with an aria-label, and the visible sticky heading is aria-hidden: the group is already named, and a bare paragraph is not a permitted child of a listbox.",
        "Escape closes the list and leaves focus in the input, and stops propagating only while the list is open, so a second Escape still reaches the dialog around it.",
        "A disabled field disables the hidden input as well as the box. An invalid field sets aria-invalid=\"true\" and points aria-describedby at real text under it, never at a title attribute."
      ],
      "related": [
        "input",
        "checkbox",
        "command-palette"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Single select",
          "html": "<!-- The box is a search field and carries no name. Name it vendor and the\n     half-typed \"guj\" posts beside the vendor id, and the server reads whichever\n     of the two came last. The hidden input is the field.\n\n     close() writes the committed label back into the box, so the field cannot\n     end up reading \"nash\" over a hidden value of gujarat-polymers.\n\n     Escape and Enter are both conditional. Escape stops propagating only while\n     the list is open, or it swallows the keystroke a surrounding dialog is\n     waiting for; Enter is prevented only while the list is open, or the field\n     takes form submission away from the keyboard. -->\n<div class=\"relative max-w-sm\"\n     x-data=\"{\n       open: false, typed: false, q: 'Gujarat Polymers Ltd', sel: 'gujarat-polymers', ai: 0,\n       options: [\n         { id: 'gujarat-polymers', label: 'Gujarat Polymers Ltd', meta: 'VEN-0142' },\n         { id: 'sharma-extrusions', label: 'Sharma Extrusions', meta: 'VEN-0187' },\n         { id: 'nashik-steel', label: 'Nashik Steel Traders', meta: 'VEN-0203' },\n         { id: 'deccan-fasteners', label: 'Deccan Fasteners Pvt Ltd', meta: 'VEN-0219' },\n         { id: 'silvassa-packaging', label: 'Silvassa Packaging and Allied Products', meta: 'VEN-0231' },\n         { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', meta: 'VEN-0244' },\n         { id: 'baroda-fasteners', label: 'Baroda Fasteners', meta: 'VEN-0258' },\n         { id: 'coimbatore-castings', label: 'Coimbatore Castings Ltd', meta: 'VEN-0266' }\n       ],\n       get list() {\n         if (!this.typed) return this.options;\n         const s = this.q.trim().toLowerCase();\n         return this.options.filter(o => (o.label + ' ' + o.meta).toLowerCase().includes(s));\n       },\n       get chosen() { return this.options.find(o => o.id === this.sel) || null; },\n       rowId(o) { return 'cb-vendor-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() {\n         if (this.open) return;\n         this.open = true; this.typed = false;\n         this.ai = Math.max(0, this.list.findIndex(o => o.id === this.sel));\n         this.scroll();\n       },\n       close() { this.open = false; this.typed = false; this.q = this.chosen ? this.chosen.label : ''; },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       edge(end) { if (!this.list.length) return; this.ai = end ? this.list.length - 1 : 0; this.scroll(); },\n       pick(o) { this.sel = o.id; this.close(); this.$refs.q.focus(); },\n       commit() { const o = this.list[this.ai]; if (o) this.pick(o); },\n       clear() { this.sel = null; this.q = ''; this.typed = false; this.open = false; this.$refs.q.focus(); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor <span class=\"text-red-600\">*</span></label>\n\n  <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n\n    <!-- no @focus handler: opening on focus pops a listbox at every combobox\n         somebody tabs through, and fires as the keyboard slides up on a phone.\n         Click, arrows and typing open it. -->\n    <input id=\"cb-vendor\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-vendor-list\" aria-describedby=\"cb-vendor-help\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           placeholder=\"Search 248 approved vendors\"\n           @click=\"show()\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.home=\"if (open) { $event.preventDefault(); edge(false) }\"\n           @keydown.end=\"if (open) { $event.preventDefault(); edge(true) }\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.tab=\"close()\"\n           class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n\n    <button type=\"button\" x-show=\"sel\" x-cloak @click=\"clear()\" aria-label=\"Clear the selected vendor\"\n            class=\"mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n      <i data-lucide=\"x\" class=\"size-4\"></i>\n    </button>\n\n    <!-- the rotation goes on a wrapping span. createIcons() replaces the <i>\n         with an <svg> and takes any binding on it with it. -->\n    <button type=\"button\" tabindex=\"-1\" aria-hidden=\"true\" @click=\"open ? close() : (show(), $refs.q.focus())\"\n            class=\"mr-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100\">\n      <span class=\"flex transition-transform motion-reduce:transition-none\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4\"></i>\n      </span>\n    </button>\n  </div>\n\n  <!-- what actually posts -->\n  <input type=\"hidden\" name=\"vendor\" :value=\"sel || ''\">\n\n  <p id=\"cb-vendor-help\" class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Only vendors with a live rate contract are listed.</p>\n\n  <!-- outside the popup and in the document from first paint. A live region\n       inserted with its text already in it announces nothing. -->\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div id=\"cb-vendor-list\" role=\"listbox\" aria-label=\"Approved vendors\" class=\"max-h-64 overflow-y-auto py-1\">\n      <template x-for=\"(o, i) in list\" :key=\"o.id\">\n        <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"o.id === sel\"\n             @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = i\"\n             :class=\"i === ai ? 'bg-zinc-100' : ''\"\n             class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n          <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.label\"></span>\n          <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"o.meta\"></span>\n          <span class=\"flex size-4 shrink-0 items-center justify-center\" x-show=\"o.id === sel\" x-cloak>\n            <i data-lucide=\"check\" class=\"size-4 text-zinc-600\"></i>\n          </span>\n        </div>\n      </template>\n    </div>\n\n    <!-- an empty popup reads as a component that broke; this reads as a search\n         that found nothing -->\n    <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Check the spelling, or search by vendor code — VEN-0142.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "multi",
          "name": "Multiselect with chips",
          "html": "<!-- One hidden input per value, all sharing the name, which Django reads with\n     request.POST.getlist('vendor'). .get() returns the last one and drops every\n     other vendor the user picked.\n\n     The chips wrap inside the field and every label truncates, so a name like\n     Silvassa Packaging and Allied Products cannot push the box wider than the\n     390px viewport it is sitting in.\n\n     Each remove button names its own vendor: twelve buttons all called Remove\n     say nothing about which chip the cursor is on. They are real tab stops,\n     which is the price of being reachable at all — Backspace on an empty query\n     is the fast way out. -->\n<div class=\"relative max-w-md\"\n     x-data=\"{\n       open: false, typed: false, q: '', ai: 0,\n       sel: ['gujarat-polymers', 'nashik-steel'],\n       options: [\n         { id: 'gujarat-polymers', label: 'Gujarat Polymers Ltd', meta: 'VEN-0142' },\n         { id: 'sharma-extrusions', label: 'Sharma Extrusions', meta: 'VEN-0187' },\n         { id: 'nashik-steel', label: 'Nashik Steel Traders', meta: 'VEN-0203' },\n         { id: 'deccan-fasteners', label: 'Deccan Fasteners Pvt Ltd', meta: 'VEN-0219' },\n         { id: 'silvassa-packaging', label: 'Silvassa Packaging and Allied Products', meta: 'VEN-0231' },\n         { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', meta: 'VEN-0244' },\n         { id: 'baroda-fasteners', label: 'Baroda Fasteners', meta: 'VEN-0258' },\n         { id: 'coimbatore-castings', label: 'Coimbatore Castings Ltd', meta: 'VEN-0266' }\n       ],\n       get list() {\n         if (!this.typed) return this.options;\n         const s = this.q.trim().toLowerCase();\n         return this.options.filter(o => (o.label + ' ' + o.meta).toLowerCase().includes(s));\n       },\n       has(id) { return this.sel.includes(id); },\n       label(id) { const o = this.options.find(x => x.id === id); return o ? o.label : id; },\n       rowId(o) { return 'cb-rfq-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() { if (!this.open) { this.open = true; this.typed = false; this.ai = 0; this.scroll(); } },\n       close() { this.open = false; this.typed = false; this.q = ''; },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       edge(end) { if (!this.list.length) return; this.ai = end ? this.list.length - 1 : 0; this.scroll(); },\n       toggle(o) {\n         this.sel = this.has(o.id) ? this.sel.filter(v => v !== o.id) : [...this.sel, o.id];\n         this.q = ''; this.typed = false;\n         this.ai = Math.max(0, this.list.findIndex(x => x.id === o.id));\n         this.$refs.q.focus();\n       },\n       drop(id) { this.sel = this.sel.filter(v => v !== id); this.$refs.q.focus(); },\n       commit() { const o = this.list[this.ai]; if (o) this.toggle(o); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-rfq\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Send this RFQ to</label>\n\n  <div @click=\"$refs.q.focus(); show()\"\n       class=\"flex flex-wrap items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2 py-1.5 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n\n    <template x-for=\"id in sel\" :key=\"id\">\n      <span class=\"inline-flex max-w-full items-center gap-1 rounded-full bg-zinc-200 py-0.5 pr-1 pl-2.5 text-[12px]/4 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"min-w-0 truncate\" x-text=\"label(id)\"></span>\n        <button type=\"button\" :aria-label=\"'Remove ' + label(id)\" @click.stop=\"drop(id)\"\n                class=\"flex size-4 shrink-0 items-center justify-center rounded-full text-zinc-600 hover:bg-zinc-300 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-3\"></i>\n        </button>\n      </span>\n    </template>\n\n    <input id=\"cb-rfq\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-rfq-list\" aria-describedby=\"cb-rfq-count\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           :placeholder=\"sel.length ? 'Search' : 'Search vendors'\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.home=\"if (open) { $event.preventDefault(); edge(false) }\"\n           @keydown.end=\"if (open) { $event.preventDefault(); edge(true) }\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.backspace=\"if (!q && sel.length) { $event.preventDefault(); sel = sel.slice(0, -1) }\"\n           @keydown.tab=\"close()\"\n           class=\"min-w-16 flex-1 bg-transparent px-1 py-1 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  </div>\n\n  <!-- one hidden input per value, all sharing the name -->\n  <template x-for=\"id in sel\" :key=\"'post-' + id\">\n    <input type=\"hidden\" name=\"vendor\" :value=\"id\">\n  </template>\n\n  <div class=\"mt-1.5 flex items-start justify-between gap-3\">\n    <p id=\"cb-rfq-count\" class=\"text-[12px]/4 tabular-nums text-zinc-500\"\n       x-text=\"sel.length ? sel.length + ' of 8 vendors selected' : 'No vendor selected — the RFQ will not be sent'\"></p>\n    <button type=\"button\" x-show=\"sel.length\" x-cloak @click=\"sel = []\"\n            class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-900 underline underline-offset-2\">\n      Clear all <span x-text=\"sel.length\"></span>\n    </button>\n  </div>\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div id=\"cb-rfq-list\" role=\"listbox\" aria-multiselectable=\"true\" aria-label=\"Approved vendors\"\n         class=\"max-h-64 overflow-y-auto py-1\">\n      <template x-for=\"(o, i) in list\" :key=\"o.id\">\n        <!-- the tick is a drawn square, not an input. Nothing inside\n             role=\"option\" may be focusable, and a real checkbox carrying a name\n             here would post every ticked vendor a second time. -->\n        <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"has(o.id)\"\n             @mousedown.prevent @click=\"toggle(o)\" @mousemove=\"ai = i\"\n             :class=\"i === ai ? 'bg-zinc-100' : ''\"\n             class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n          <span class=\"flex size-4 shrink-0 items-center justify-center rounded\"\n                :class=\"has(o.id) ? 'bg-zinc-700 text-white' : 'bg-white ring-1 ring-inset ring-zinc-300'\">\n            <span class=\"flex\" x-show=\"has(o.id)\" x-cloak><i data-lucide=\"check\" class=\"size-3\"></i></span>\n          </span>\n          <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.label\"></span>\n          <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"o.meta\"></span>\n        </div>\n      </template>\n    </div>\n\n    <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Search by vendor code instead — VEN-0142.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "select-all",
          "name": "Select all, scoped to the filter",
          "html": "<!-- Select all takes the rows the filter is showing, and the button says the\n     number out loud: \"Select all 4 matching\" with a query typed, \"Select all 9\"\n     without one. A bulk control that quietly reaches past what is on screen is\n     the same defect as a select-all that approves 4,312 orders when five are\n     visible — people can only verify what they can see.\n\n     Clear is deliberately not scoped the same way. It releases everything and\n     its label carries the total, because a Clear that only released the\n     matching rows would leave \"3 selected\" standing over a list with nothing\n     ticked in it, and no route back to the other three.\n\n     The line under the buttons only appears while a query is typed, so the\n     asymmetry is on screen rather than in the documentation. -->\n<div class=\"relative max-w-md\"\n     x-data=\"{\n       open: false, typed: false, q: '', ai: 0,\n       sel: ['cc-1200', 'cc-3100'],\n       options: [\n         { id: 'cc-1100', label: 'Injection moulding', meta: 'CC-1100' },\n         { id: 'cc-1200', label: 'Extrusion', meta: 'CC-1200' },\n         { id: 'cc-1300', label: 'Blow moulding', meta: 'CC-1300' },\n         { id: 'cc-2100', label: 'Tool room', meta: 'CC-2100' },\n         { id: 'cc-2200', label: 'Maintenance', meta: 'CC-2200' },\n         { id: 'cc-3100', label: 'Quality lab', meta: 'CC-3100' },\n         { id: 'cc-3200', label: 'Stores', meta: 'CC-3200' },\n         { id: 'cc-4100', label: 'Dispatch', meta: 'CC-4100' },\n         { id: 'cc-5100', label: 'Plant administration', meta: 'CC-5100' }\n       ],\n       get filtering() { return this.typed && this.q.trim().length > 0; },\n       get list() {\n         if (!this.filtering) return this.options;\n         const s = this.q.trim().toLowerCase();\n         return this.options.filter(o => (o.label + ' ' + o.meta).toLowerCase().includes(s));\n       },\n       get allShown() { return this.list.length > 0 && this.list.every(o => this.sel.includes(o.id)); },\n       cap: 1,\n       get chips() { return this.sel.length > this.cap ? this.sel.slice(0, 1) : this.sel; },\n       get extra() { return this.sel.length - this.chips.length; },\n       has(id) { return this.sel.includes(id); },\n       label(id) { const o = this.options.find(x => x.id === id); return o ? o.label : id; },\n       drop(id) { this.sel = this.sel.filter(v => v !== id); this.$refs.q.focus(); },\n       rowId(o) { return 'cb-cc-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() { if (!this.open) { this.open = true; this.typed = false; this.ai = 0; this.scroll(); } },\n       close() { this.open = false; this.typed = false; this.q = ''; },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       toggle(o) {\n         this.sel = this.has(o.id) ? this.sel.filter(v => v !== o.id) : [...this.sel, o.id];\n         this.$refs.q.focus();\n       },\n       takeShown() { this.sel = [...new Set([...this.sel, ...this.list.map(o => o.id)])]; this.$refs.q.focus(); },\n       clearAll() { this.sel = []; this.$refs.q.focus(); },\n       commit() { const o = this.list[this.ai]; if (o) this.toggle(o); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-cc\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Cost centres this expense is split across</label>\n\n  <!-- Select all is the control that produces a wall of chips, so the field\n       lists one name and collapses everything past it into a +N more pill\n       that opens the list. The cap is one, not two: a 390px viewport gives this\n       field 300px, and two names plus the search input do not fit on one line —\n       measured, the box stood at 42px with one selection, grew to 68px at two,\n       and dropped back to 42px at three, which reads as the field flinching. One\n       chip plus the pill holds one row at every count and every width here.\n\n       Nothing is hidden from the keyboard by the cap: every option, listed or\n       not, is a row in the popup with its own tick, and toggling it there is\n       what releases it. A field carrying only a count — 3 / 9 — is what this\n       replaced, and it left a selection nobody could see without opening the\n       popup first. -->\n  <div @click=\"$refs.q.focus(); show()\"\n       class=\"flex flex-wrap items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2 py-1.5 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n\n    <template x-for=\"id in chips\" :key=\"id\">\n      <span class=\"inline-flex max-w-full items-center gap-1 rounded-full bg-zinc-200 py-0.5 pr-1 pl-2.5 text-[12px]/4 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"min-w-0 truncate\" x-text=\"label(id)\"></span>\n        <button type=\"button\" :aria-label=\"'Remove ' + label(id)\" @click.stop=\"drop(id)\"\n                class=\"flex size-4 shrink-0 items-center justify-center rounded-full text-zinc-600 hover:bg-zinc-300 hover:text-zinc-900\">\n          <i data-lucide=\"x\" class=\"size-3\"></i>\n        </button>\n      </span>\n    </template>\n\n    <button type=\"button\" x-show=\"extra\" x-cloak @click.stop=\"$refs.q.focus(); show()\"\n            :aria-label=\"extra + ' more selected — open the list to see all ' + sel.length\"\n            class=\"inline-flex shrink-0 items-center rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n      <span x-text=\"'+' + extra + ' more'\"></span>\n    </button>\n\n    <input id=\"cb-cc\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-cc-list\" aria-describedby=\"cb-cc-count\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           :placeholder=\"sel.length ? 'Search' : 'Search cost centres'\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.backspace=\"if (!q && sel.length) { $event.preventDefault(); sel = sel.slice(0, -1) }\"\n           @keydown.tab=\"close()\"\n           class=\"min-w-16 flex-1 bg-transparent px-1 py-1 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  </div>\n\n  <template x-for=\"id in sel\" :key=\"'post-' + id\">\n    <input type=\"hidden\" name=\"cost_centre\" :value=\"id\">\n  </template>\n\n  <p id=\"cb-cc-count\" class=\"mt-1.5 text-[12px]/4 tabular-nums text-zinc-500\"\n     x-text=\"sel.length ? sel.length + ' of 9 cost centres selected' : 'No cost centre selected — the expense stays unallocated'\"></p>\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (list.length === 1 ? '1 cost centre matches' : list.length + ' cost centres match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div class=\"flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-zinc-200 px-3 py-2\">\n      <button type=\"button\" @mousedown.prevent @click=\"takeShown()\" :disabled=\"allShown\"\n              class=\"text-[12px]/4 font-medium tabular-nums text-zinc-900 underline underline-offset-2 disabled:text-zinc-400 disabled:no-underline\">\n        <span x-text=\"filtering ? 'Select all ' + list.length + ' matching' : 'Select all ' + options.length\"></span>\n      </button>\n      <button type=\"button\" @mousedown.prevent @click=\"clearAll()\" :disabled=\"!sel.length\"\n              class=\"text-[12px]/4 tabular-nums text-zinc-600 underline underline-offset-2 disabled:text-zinc-400 disabled:no-underline\">\n        <span x-text=\"'Clear all ' + sel.length\"></span>\n      </button>\n    </div>\n\n    <p x-show=\"filtering\" x-cloak\n       class=\"border-b border-zinc-200 bg-zinc-100 px-3 py-1.5 text-[12px]/4 tabular-nums text-zinc-600\"\n       x-text=\"'Select all takes the ' + list.length + ' rows shown. The other ' + (options.length - list.length) + ' are left as they are, and Clear releases all ' + sel.length + '.'\"></p>\n\n    <div id=\"cb-cc-list\" role=\"listbox\" aria-multiselectable=\"true\" aria-label=\"Cost centres\"\n         class=\"max-h-64 overflow-y-auto py-1\">\n      <template x-for=\"(o, i) in list\" :key=\"o.id\">\n        <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"has(o.id)\"\n             @mousedown.prevent @click=\"toggle(o)\" @mousemove=\"ai = i\"\n             :class=\"i === ai ? 'bg-zinc-100' : ''\"\n             class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n          <span class=\"flex size-4 shrink-0 items-center justify-center rounded\"\n                :class=\"has(o.id) ? 'bg-zinc-700 text-white' : 'bg-white ring-1 ring-inset ring-zinc-300'\">\n            <span class=\"flex\" x-show=\"has(o.id)\" x-cloak><i data-lucide=\"check\" class=\"size-3\"></i></span>\n          </span>\n          <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.label\"></span>\n          <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"o.meta\"></span>\n        </div>\n      </template>\n    </div>\n\n    <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No cost centre matches “<span x-text=\"q\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Cost centres run from CC-1100 upwards.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "groups",
          "name": "Grouped options",
          "html": "<!-- The keyboard runs over one flattened list in the order the rows are drawn,\n     not group by group. Index within a group and arrow-down at the bottom of\n     Maharashtra has nowhere to go: the highlight sticks there and Gujarat is\n     unreachable without a mouse.\n\n     Each group is role=\"group\" with an aria-label, because a listbox may only\n     contain options and groups. The visible heading is aria-hidden — the group\n     is already named, and the heading would otherwise be read as a row.\n\n     A sticky heading needs a background of its own. Left transparent, the rows\n     scroll straight through the text and neither is readable. A group whose\n     options are all filtered out goes with its heading. -->\n<div class=\"relative max-w-md\"\n     x-data=\"{\n       open: false, typed: false, q: 'Nashik Steel Traders', sel: 'nashik-steel', ai: 0,\n       groups: [\n         { state: 'Maharashtra', options: [\n           { id: 'sharma-extrusions', label: 'Sharma Extrusions', meta: 'Nashik' },\n           { id: 'nashik-steel', label: 'Nashik Steel Traders', meta: 'Nashik' },\n           { id: 'deccan-fasteners', label: 'Deccan Fasteners Pvt Ltd', meta: 'Pune' },\n           { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', meta: 'Ratnagiri' }\n         ] },\n         { state: 'Gujarat', options: [\n           { id: 'gujarat-polymers', label: 'Gujarat Polymers Ltd', meta: 'Vadodara' },\n           { id: 'baroda-fasteners', label: 'Baroda Fasteners', meta: 'Vadodara' },\n           { id: 'rajkot-forge', label: 'Rajkot Forge and Machining', meta: 'Rajkot' }\n         ] },\n         { state: 'Tamil Nadu', options: [\n           { id: 'coimbatore-castings', label: 'Coimbatore Castings Ltd', meta: 'Coimbatore' },\n           { id: 'madurai-rubber', label: 'Madurai Rubber Works', meta: 'Madurai' }\n         ] }\n       ],\n       get all() { return this.groups.flatMap(g => g.options); },\n       match(g) {\n         if (!this.typed) return g.options;\n         const s = this.q.trim().toLowerCase();\n         return g.options.filter(o => (o.label + ' ' + o.meta + ' ' + g.state).toLowerCase().includes(s));\n       },\n       get list() { return this.groups.flatMap(g => this.match(g)); },\n       rowId(o) { return 'cb-state-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() {\n         if (this.open) return;\n         this.open = true; this.typed = false;\n         this.ai = Math.max(0, this.list.findIndex(o => o.id === this.sel));\n         this.scroll();\n       },\n       close() {\n         this.open = false; this.typed = false;\n         const o = this.all.find(x => x.id === this.sel);\n         this.q = o ? o.label : '';\n       },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       pick(o) { this.sel = o.id; this.close(); this.$refs.q.focus(); },\n       commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-state\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Ship-from vendor</label>\n\n  <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n    <input id=\"cb-state\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-state-list\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           placeholder=\"Search a vendor or a state\"\n           @click=\"show()\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.tab=\"close()\"\n           class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  </div>\n\n  <input type=\"hidden\" name=\"vendor\" :value=\"sel || ''\">\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div id=\"cb-state-list\" role=\"listbox\" aria-label=\"Vendors by state\" class=\"max-h-72 overflow-y-auto pb-1\">\n      <template x-for=\"g in groups\" :key=\"g.state\">\n        <div role=\"group\" :aria-label=\"g.state\" x-show=\"match(g).length\">\n          <p aria-hidden=\"true\"\n             class=\"sticky top-0 z-10 border-b border-zinc-100 bg-white px-3 py-1.5 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\"\n             x-text=\"g.state\"></p>\n          <template x-for=\"o in match(g)\" :key=\"o.id\">\n            <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"o.id === sel\"\n                 @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = list.findIndex(x => x.id === o.id)\"\n                 :class=\"list[ai] && list[ai].id === o.id ? 'bg-zinc-100' : ''\"\n                 class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n              <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.label\"></span>\n              <span class=\"shrink-0 text-[12px]/4 text-zinc-500\" x-text=\"o.meta\"></span>\n              <span class=\"flex size-4 shrink-0 items-center justify-center\" x-show=\"o.id === sel\" x-cloak>\n                <i data-lucide=\"check\" class=\"size-4 text-zinc-600\"></i>\n              </span>\n            </div>\n          </template>\n        </div>\n      </template>\n    </div>\n\n    <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500\">Try a state — Maharashtra, Gujarat, Tamil Nadu.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "rich",
          "name": "Two-line options",
          "html": "<!-- A second line is what makes two vendors called Sharma tell each other\n     apart. It carries the identifying facts — code, GSTIN, last order — and\n     nothing decorative.\n\n     Every row gets an explicit aria-label, because left alone a screen reader\n     reads the whole cell as one run: the name, then a GSTIN spelled out\n     character by character, then a date, then an amount. The label is the name\n     and the code; the rest is aria-hidden and stays on screen for the people\n     reading it.\n\n     Below sm the GSTIN is dropped rather than wrapped. Three facts on a 390px\n     row is one too many, and the vendor code is the one people search by. -->\n<div class=\"relative max-w-lg\"\n     x-data=\"{\n       open: false, typed: false, q: 'Sharma Extrusions', sel: 'sharma-extrusions', ai: 0,\n       options: [\n         { id: 'sharma-extrusions', label: 'Sharma Extrusions', code: 'VEN-0187', gstin: '27AABCS9012K1Z5', last: '14 Aug 2026', spend: '₹18,42,000' },\n         { id: 'sharma-polymers', label: 'Sharma Polymers and Compounds', code: 'VEN-0192', gstin: '24AABCS4471D1ZM', last: '02 Aug 2026', spend: '₹6,90,400' },\n         { id: 'nashik-steel', label: 'Nashik Steel Traders', code: 'VEN-0203', gstin: '27AACCN4455P1ZR', last: '19 Aug 2026', spend: '₹4,68,500' },\n         { id: 'deccan-fasteners', label: 'Deccan Fasteners Pvt Ltd', code: 'VEN-0219', gstin: '27AAECD7788M1ZT', last: '28 Jul 2026', spend: '₹96,750' },\n         { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', code: 'VEN-0244', gstin: '27AAGCK2266H1ZW', last: '11 Jun 2026', spend: '₹1,32,900' },\n         { id: 'coimbatore-castings', label: 'Coimbatore Castings Ltd', code: 'VEN-0266', gstin: '33AAJCC8811N1ZD', last: '04 Mar 2026', spend: '₹27,10,400' }\n       ],\n       get list() {\n         if (!this.typed) return this.options;\n         const s = this.q.trim().toLowerCase();\n         return this.options.filter(o => (o.label + ' ' + o.code + ' ' + o.gstin).toLowerCase().includes(s));\n       },\n       get chosen() { return this.options.find(o => o.id === this.sel) || null; },\n       rowId(o) { return 'cb-rich-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() {\n         if (this.open) return;\n         this.open = true; this.typed = false;\n         this.ai = Math.max(0, this.list.findIndex(o => o.id === this.sel));\n         this.scroll();\n       },\n       close() { this.open = false; this.typed = false; this.q = this.chosen ? this.chosen.label : ''; },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       pick(o) { this.sel = o.id; this.close(); this.$refs.q.focus(); },\n       commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-rich\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor</label>\n\n  <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <i data-lucide=\"building-2\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n    <input id=\"cb-rich\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-rich-list\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           placeholder=\"Name, vendor code or GSTIN\"\n           @click=\"show()\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.tab=\"close()\"\n           class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  </div>\n\n  <input type=\"hidden\" name=\"vendor\" :value=\"sel || ''\">\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div id=\"cb-rich-list\" role=\"listbox\" aria-label=\"Vendors\" class=\"max-h-80 overflow-y-auto py-1\">\n      <template x-for=\"(o, i) in list\" :key=\"o.id\">\n        <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"o.id === sel\" :aria-label=\"o.label + ', ' + o.code\"\n             @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = i\"\n             :class=\"i === ai ? 'bg-zinc-100' : ''\"\n             class=\"flex items-start gap-3 px-3 py-2\">\n          <span class=\"min-w-0 flex-1\" aria-hidden=\"true\">\n            <span class=\"flex items-baseline justify-between gap-3\">\n              <span class=\"min-w-0 truncate text-[13px]/5 font-medium\" x-text=\"o.label\"></span>\n              <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\" x-text=\"o.spend\"></span>\n            </span>\n            <span class=\"mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px]/4 tabular-nums text-zinc-500\">\n              <span x-text=\"o.code\"></span>\n              <span class=\"hidden sm:inline\">·</span>\n              <span class=\"hidden font-mono sm:inline\" x-text=\"o.gstin\"></span>\n              <span>·</span>\n              <span x-text=\"'last order ' + o.last\"></span>\n            </span>\n          </span>\n          <span class=\"mt-0.5 flex size-4 shrink-0 items-center justify-center\" x-show=\"o.id === sel\" x-cloak>\n            <i data-lucide=\"check\" class=\"size-4 text-zinc-600\"></i>\n          </span>\n        </div>\n      </template>\n    </div>\n\n    <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">A GSTIN search needs all 15 characters.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "remote",
          "name": "Options fetched with htmx",
          "html": "<!-- htmx makes the request; Alpine never fetches. Alpine owns open, the\n     highlight and the keyboard, htmx owns the rows.\n\n     Because htmx replaced the rows without telling Alpine, there is no array to\n     index into. The keyboard reads the option elements out of the DOM on every\n     move and paints the highlight itself; an Alpine array here goes stale the\n     moment the first response lands. That also means the ids come from the\n     server, and they still have to be unique — aria-activedescendant points at\n     one of them.\n\n     The filter binds to the event name, not to the end of the spec:\n     input[this.value.length > 1] changed delay:300ms. Written the other way\n     round it is parsed as part of the modifier and the two-character floor\n     never applies. delay is a debounce, not a throttle, and hx-sync\n     this:replace drops a request still in flight so two keystrokes inside\n     300ms cannot settle on the older reply.\n\n     Below the floor nothing fires at all, so deleting back to one character\n     clears the rows in script — otherwise last search's answer sits there under\n     a query that did not produce it.\n\n     The search box is named q, never vendor. The value is the hidden input, and\n     a Django form ignores a POST key it has no field for. -->\n<div class=\"relative max-w-md\"\n     x-data=\"{\n       open: false, loading: false, failed: false, searched: false, empty: false,\n       ai: 0, aid: null, sel: '', label: '', term: '',\n       rows() { return Array.from(this.$refs.list.querySelectorAll('[role=option]:not([aria-disabled=true])')); },\n       paint() {\n         const r = this.rows();\n         r.forEach((el, i) => el.classList.toggle('bg-zinc-100', i === this.ai));\n         const el = r[this.ai];\n         this.aid = el ? el.id : null;\n         if (el) el.scrollIntoView({ block: 'nearest' });\n       },\n       move(n) {\n         if (!this.open) { this.open = true; return; }\n         const r = this.rows();\n         if (!r.length) return;\n         this.ai = Math.min(r.length - 1, Math.max(0, this.ai + n));\n         this.paint();\n       },\n       take(el) {\n         if (!el) return;\n         this.sel = el.dataset.value; this.label = el.dataset.label; this.term = this.label;\n         this.rows().forEach(r => r.setAttribute('aria-selected', r === el));\n         this.$refs.q.value = this.label;\n         this.open = false; this.aid = null;\n         this.$refs.q.focus();\n       },\n       commit() { this.take(this.rows()[this.ai]); },\n       changed(v) {\n         this.term = v; this.open = true; this.failed = false;\n         if (v.trim().length < 2) { this.searched = false; this.empty = false; this.$refs.list.innerHTML = ''; }\n       },\n       close() { this.open = false; this.aid = null; this.term = this.label; this.$refs.q.value = this.label; }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\"\n     @htmx:before-request.camel=\"loading = true; failed = false; open = true\"\n     @htmx:after-request.camel=\"loading = false; if (!$event.detail.successful) failed = true\"\n     @htmx:after-swap.camel=\"searched = true; ai = 0; $nextTick(() => { empty = rows().length === 0; paint() })\">\n\n  <label for=\"cb-remote\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor</label>\n\n  <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n    <input id=\"cb-remote\" x-ref=\"q\" type=\"text\" name=\"q\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-remote-list\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"aid\"\n           placeholder=\"Type two characters to search\"\n           hx-get=\"/vendors/search/\"\n           hx-trigger=\"input[this.value.length > 1] changed delay:300ms\"\n           hx-target=\"#cb-remote-list\" hx-swap=\"innerHTML\" hx-sync=\"this:replace\"\n           @click=\"open = true\"\n           @input=\"changed($event.target.value)\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.tab=\"close()\"\n           class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n    <span class=\"mr-3 flex size-4 shrink-0 items-center justify-center\" x-show=\"loading\" x-cloak>\n      <i data-lucide=\"loader-circle\" class=\"size-4 animate-spin text-zinc-600\"></i>\n    </span>\n  </div>\n\n  <input type=\"hidden\" name=\"vendor\" :value=\"sel\">\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"loading ? 'Searching vendors' : (searched ? (empty ? 'No vendor matches' : 'Vendors listed') : '')\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <!-- htmx swaps the rows in here. One delegated handler, so the fragment the\n         server sends carries no behaviour of its own — only data-value and\n         data-label. -->\n    <div id=\"cb-remote-list\" x-ref=\"list\" role=\"listbox\" aria-label=\"Vendors\"\n         class=\"max-h-64 overflow-y-auto py-1 empty:hidden\"\n         @click=\"take($event.target.closest('[role=option]'))\"\n         @mousemove=\"const el = $event.target.closest('[role=option]'); if (el) { ai = rows().indexOf(el); paint() }\"></div>\n\n    <div x-show=\"!searched && !loading\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 tabular-nums text-zinc-600\">Type two characters to search 248 vendors.</p>\n    </div>\n\n    <div x-show=\"loading\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"flex items-center justify-center gap-2 text-[13px]/5 text-zinc-500\">\n        <i data-lucide=\"loader-circle\" class=\"size-4 shrink-0 animate-spin\"></i>Searching\n      </p>\n    </div>\n\n    <div x-show=\"searched && empty && !loading && !failed\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"term\"></span>”</p>\n      <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">248 vendors searched by name, code and GSTIN.</p>\n    </div>\n\n    <div x-show=\"failed\" x-cloak class=\"px-4 py-6 text-center\">\n      <p class=\"flex items-center justify-center gap-1.5 text-[13px]/5 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>The vendor search did not answer\n      </p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500\">Type another character to try again.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "create",
          "name": "A value that is not in the list",
          "html": "<!-- The create row is a real option: last in the list, reachable by arrow key,\n     committed by Enter. It appears only when there is a query and no exact\n     match, so it never sits under a list somebody is still reading.\n\n     It posts as text in a second field, never as an invented id. Send\n     vendor=new-konkan-industries and the server cannot tell a record that\n     exists from one that does not; send an empty vendor with vendor_new set and\n     the view knows to create one, and can put it behind whatever approval a new\n     vendor needs.\n\n     This is also why the arrows clamp rather than wrap. A list that wrapped\n     would land on Add new vendor every time somebody overshot the bottom. -->\n<div class=\"relative max-w-md\"\n     x-data=\"{\n       open: false, typed: false, q: '', sel: '', fresh: '', ai: 0,\n       options: [\n         { id: 'gujarat-polymers', label: 'Gujarat Polymers Ltd', meta: 'VEN-0142' },\n         { id: 'sharma-extrusions', label: 'Sharma Extrusions', meta: 'VEN-0187' },\n         { id: 'nashik-steel', label: 'Nashik Steel Traders', meta: 'VEN-0203' },\n         { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', meta: 'VEN-0244' }\n       ],\n       get term() { return this.q.trim(); },\n       get found() {\n         if (!this.typed) return this.options;\n         const s = this.term.toLowerCase();\n         return this.options.filter(o => (o.label + ' ' + o.meta).toLowerCase().includes(s));\n       },\n       get creating() {\n         return this.term.length > 0 &&\n                !this.options.some(o => o.label.toLowerCase() === this.term.toLowerCase());\n       },\n       get list() {\n         return this.creating ? [...this.found, { id: '__new', label: this.term, isNew: true }] : this.found;\n       },\n       rowId(o) { return 'cb-new-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() { if (!this.open) { this.open = true; this.typed = false; this.ai = 0; this.scroll(); } },\n       close() {\n         this.open = false; this.typed = false;\n         const o = this.options.find(x => x.id === this.sel);\n         this.q = o ? o.label : this.fresh;\n       },\n       move(n) {\n         if (!this.open) { this.show(); return; }\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       pick(o) {\n         if (o.isNew) { this.sel = ''; this.fresh = o.label; }\n         else { this.sel = o.id; this.fresh = ''; }\n         this.close();\n         this.$refs.q.focus();\n       },\n       commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n     }\"\n     @click.outside=\"close()\"\n     @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n  <label for=\"cb-new\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor on the quotation</label>\n\n  <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n    <input id=\"cb-new\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n           aria-autocomplete=\"list\" aria-controls=\"cb-new-list\" aria-describedby=\"cb-new-help\"\n           :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n           placeholder=\"Search, or type a new vendor name\"\n           @click=\"show()\"\n           @input=\"typed = true; open = true; ai = 0\"\n           @keydown.arrow-down.prevent=\"move(1)\"\n           @keydown.arrow-up.prevent=\"move(-1)\"\n           @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n           @keydown.tab=\"close()\"\n           class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n  </div>\n\n  <!-- an id for a record that exists, or the raw text for one that does not.\n       Never a made-up id. -->\n  <input type=\"hidden\" name=\"vendor\" :value=\"sel\">\n  <input type=\"hidden\" name=\"vendor_new\" :value=\"fresh\">\n\n  <p id=\"cb-new-help\" class=\"mt-1.5 text-[12px]/4\" :class=\"fresh ? 'text-amber-700' : 'text-zinc-500'\"\n     x-text=\"fresh ? 'New vendor — this order cannot be released until purchase approves it.' : 'Pick an approved vendor, or add one and send it for approval.'\"></p>\n\n  <p role=\"status\" class=\"sr-only\"\n     x-text=\"open ? (found.length === 1 ? '1 vendor matches' : found.length + ' vendors match') : ''\"></p>\n\n  <div x-show=\"open\" x-cloak\n       class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n    <div id=\"cb-new-list\" role=\"listbox\" aria-label=\"Vendors\" class=\"max-h-64 overflow-y-auto py-1\">\n      <template x-for=\"(o, i) in list\" :key=\"o.id\">\n        <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"!o.isNew && o.id === sel\"\n             @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = i\"\n             :class=\"[i === ai ? 'bg-zinc-100' : '', o.isNew ? 'border-t border-zinc-100' : '']\"\n             class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n          <span class=\"flex size-4 shrink-0 items-center justify-center\" x-show=\"o.isNew\" x-cloak>\n            <i data-lucide=\"plus\" class=\"size-4 text-zinc-600\"></i>\n          </span>\n          <span class=\"min-w-0 flex-1 truncate\"\n                x-text=\"o.isNew ? 'Add new vendor: “' + o.label + '”' : o.label\"></span>\n          <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-show=\"!o.isNew\" x-text=\"o.meta\"></span>\n        </div>\n      </template>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "states",
          "name": "Disabled, locked, invalid",
          "html": "<div class=\"max-w-md space-y-7\">\n\n  <!-- Disabled reaches the hidden input too. disabled on the search box only\n       stops the typing; the hidden input has no appearance of its own and posts\n       whatever it holds, so a field somebody was told they cannot change still\n       submits its value. The three fields here carry different names only\n       because they sit in one example — in a real form all three are vendor. -->\n  <div>\n    <label for=\"cb-off\" class=\"mb-1.5 block text-[13px]/5 font-medium text-zinc-500\">Vendor</label>\n    <div class=\"flex items-center rounded-lg border border-zinc-200 bg-zinc-100\">\n      <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-400\"></i>\n      <!-- no aria-controls: there is no popup in the document to point it at,\n           and aria-controls naming an id that does not exist is worse than\n           leaving it off -->\n      <input id=\"cb-off\" type=\"text\" disabled value=\"Gujarat Polymers Ltd\"\n             role=\"combobox\" aria-expanded=\"false\"\n             class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 text-zinc-400\">\n      <span class=\"mr-3 flex size-4 shrink-0 items-center justify-center\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-400\"></i>\n      </span>\n    </div>\n    <input type=\"hidden\" name=\"vendor_disabled\" value=\"gujarat-polymers\" disabled>\n    <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Set on the rate contract. It changes there, not here.</p>\n  </div>\n\n  <!-- A locked value is rendered, not made read-only. There is no read-only\n       combobox: readonly leaves the box focusable, the chevron still opens the\n       list, and readonly means nothing at all on an option row. The value still\n       has to reach the server, so a hidden input goes with the text. -->\n  <div>\n    <p class=\"text-[13px]/5 font-medium text-zinc-600\">Vendor</p>\n    <p class=\"mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[14px]/5\">\n      <i data-lucide=\"lock\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      Gujarat Polymers Ltd\n      <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">VEN-0142</span>\n    </p>\n    <input type=\"hidden\" name=\"vendor_locked\" value=\"gujarat-polymers\">\n    <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Fixed when GRN-24-0912 was posted against this order.</p>\n  </div>\n\n  <!-- Invalid, with two locked rows in the list. The red border is drawn;\n       aria-invalid and aria-describedby are what gets announced. -->\n  <div class=\"relative\"\n       x-data=\"{\n         open: false, typed: false, q: 'Bhiwandi Traders', sel: '', ai: 0,\n         options: [\n           { id: 'gujarat-polymers', label: 'Gujarat Polymers Ltd', meta: 'VEN-0142', blocked: false },\n           { id: 'sharma-extrusions', label: 'Sharma Extrusions', meta: 'VEN-0187', blocked: false },\n           { id: 'nashik-steel', label: 'Nashik Steel Traders', meta: 'VEN-0203', blocked: true },\n           { id: 'konkan-chemicals', label: 'Konkan Chemicals Pvt Ltd', meta: 'VEN-0244', blocked: true },\n           { id: 'baroda-fasteners', label: 'Baroda Fasteners', meta: 'VEN-0258', blocked: false }\n         ],\n         get list() {\n           if (!this.typed) return this.options;\n           const s = this.q.trim().toLowerCase();\n           return this.options.filter(o => (o.label + ' ' + o.meta).toLowerCase().includes(s));\n         },\n         get chosen() { return this.options.find(o => o.id === this.sel) || null; },\n         rowId(o) { return 'cb-bad-' + o.id; },\n         get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n         scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n         next(from, step) {\n           for (let i = from + step; i >= 0 && i < this.list.length; i += step) {\n             if (!this.list[i].blocked) return i;\n           }\n           return from < 0 ? 0 : from;\n         },\n         show() { if (!this.open) { this.open = true; this.typed = false; this.ai = this.next(-1, 1); this.scroll(); } },\n         close() { this.open = false; this.typed = false; this.q = this.chosen ? this.chosen.label : ''; },\n         move(step) {\n           if (!this.open) { this.show(); return; }\n           if (!this.list.length) return;\n           this.ai = this.next(this.ai, step);\n           this.scroll();\n         },\n         pick(o) { if (o.blocked) return; this.sel = o.id; this.close(); this.$refs.q.focus(); },\n         commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n       }\"\n       @click.outside=\"close()\"\n       @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n    <label for=\"cb-bad\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor <span class=\"text-red-600\">*</span></label>\n\n    <div class=\"flex items-center rounded-lg border border-red-600 bg-white focus-within:ring-3 focus-within:ring-red-600/15\">\n      <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n      <input id=\"cb-bad\" x-ref=\"q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\"\n             aria-autocomplete=\"list\" aria-controls=\"cb-bad-list\"\n             aria-invalid=\"true\" aria-describedby=\"cb-bad-err\"\n             :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n             @click=\"show()\"\n             @input=\"typed = true; open = true; ai = 0\"\n             @keydown.arrow-down.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-1)\"\n             @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n             @keydown.tab=\"close()\"\n             class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none\">\n    </div>\n\n    <input type=\"hidden\" name=\"vendor\" :value=\"sel\">\n\n    <p id=\"cb-bad-err\" class=\"mt-1.5 flex items-start gap-1.5 text-[12px]/4 font-medium text-red-600\">\n      <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>\n      Pick a vendor from the list. Typed text is not a vendor.\n    </p>\n\n    <p role=\"status\" class=\"sr-only\"\n       x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n    <div x-show=\"open\" x-cloak\n         class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n      <!-- aria-disabled announces the state and blocks nothing at all. next()\n           steps over the blocked rows and pick() returns early, or the row is\n           unreachable by keyboard and fully clickable by mouse. It keeps its\n           place in the list: dropping it changes the shape of a set people scan\n           by position. -->\n      <div id=\"cb-bad-list\" role=\"listbox\" aria-label=\"Vendors\" class=\"max-h-64 overflow-y-auto py-1\">\n        <template x-for=\"(o, i) in list\" :key=\"o.id\">\n          <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"o.id === sel\" :aria-disabled=\"o.blocked\"\n               @mousedown.prevent @click=\"pick(o)\" @mousemove=\"if (!o.blocked) ai = i\"\n               :class=\"[i === ai && !o.blocked ? 'bg-zinc-100' : '', o.blocked ? 'text-zinc-400' : '']\"\n               class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n            <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.label\"></span>\n            <span class=\"shrink-0 text-[12px]/4 text-zinc-500\" x-show=\"o.blocked\" x-cloak>Rate contract expired</span>\n            <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-show=\"!o.blocked\" x-text=\"o.meta\"></span>\n          </div>\n        </template>\n      </div>\n\n      <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n        <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n        <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Two of these five are locked until their rate contract is renewed.</p>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form field",
          "html": "<!-- forms.py\n     class RfqForm(forms.Form):\n         # Single select. The widget is never rendered — the template below draws\n         # the field — but the field still validates the id against the queryset,\n         # which is the only thing between a hidden input and any primary key\n         # somebody cares to type into it with the dev tools open.\n         vendor = forms.ModelChoiceField(\n             queryset=Vendor.objects.filter(is_active=True),\n             widget=forms.HiddenInput,\n             label='Vendor')\n\n         # Multiselect. The widget matters here even unrendered, because it is\n         # what parses the POST. MultipleHiddenInput.value_from_datadict calls\n         # data.getlist(name); a plain HiddenInput calls data.get(name), which\n         # returns the last hidden input and drops every other vendor the user\n         # picked. SelectMultiple works, for the same reason.\n         recipients = forms.ModelMultipleChoiceField(\n             queryset=Vendor.objects.filter(is_active=True),\n             widget=forms.MultipleHiddenInput,\n             required=False,\n             label='Send this RFQ to')\n\n     views.py\n         def rfq_new(request):\n             form = RfqForm(request.POST or None)\n             vendors = list(Vendor.objects.filter(is_active=True)\n                                          .values('id', 'name', 'code'))\n             return render(request, 'rfq/new.html',\n                           {'form': form, 'vendors': vendors})\n\n         def vendor_search(request):\n             q = request.GET.get('q', '').strip()\n             hits = (Vendor.objects.filter(is_active=True)\n                     .filter(Q(name__icontains=q) | Q(code__icontains=q)\n                             | Q(gstin__icontains=q))[:20]\n                     if len(q) > 1 else Vendor.objects.none())\n             # the fragment only: no base template, no <html>\n             return render(request, 'rfq/_vendor_options.html', {'hits': hits})\n\n     urls.py\n         path('rfq/new/', views.rfq_new, name='rfq-new'),\n         path('vendors/search/', views.vendor_search, name='vendor-search'),\n\n     The options go through json_script. Never interpolate a queryset into an\n     x-data attribute: one vendor called M/s D'Souza Traders ends the attribute\n     early and the whole component stops parsing. json_script escapes for\n     exactly that and leaves the JSON somewhere Alpine can read it.\n\n     The search endpoint is a GET and needs no CSRF token. A combobox that has\n     to POST its search — a long query, a complex filter set — needs\n     hx-headers='{\"X-CSRFToken\": \"{{ csrf_token }}\"}' on the input, or Django\n     answers 403. -->\n\n{{ vendors|json_script:\"vendor-options\" }}\n{{ form.recipients.value|default:''|json_script:\"rfq-recipients\" }}\n\n<form method=\"post\" class=\"max-w-md\">\n  {% csrf_token %}\n\n  <div class=\"relative\"\n       x-data=\"{\n         open: false, typed: false, ai: 0, q: '',\n         sel: '{{ form.vendor.value|default:'' }}',\n         options: JSON.parse(document.getElementById('vendor-options').textContent),\n         get list() {\n           if (!this.typed) return this.options;\n           const s = this.q.trim().toLowerCase();\n           return this.options.filter(o => (o.name + ' ' + o.code).toLowerCase().includes(s));\n         },\n         get chosen() { return this.options.find(o => String(o.id) === String(this.sel)) || null; },\n         rowId(o) { return 'dj-vendor-' + o.id; },\n         get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n         scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n         show() {\n           if (this.open) return;\n           this.open = true; this.typed = false;\n           this.ai = Math.max(0, this.list.findIndex(o => String(o.id) === String(this.sel)));\n           this.scroll();\n         },\n         close() { this.open = false; this.typed = false; this.q = this.chosen ? this.chosen.name : ''; },\n         move(n) {\n           if (!this.open) { this.show(); return; }\n           if (!this.list.length) return;\n           this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n           this.scroll();\n         },\n         pick(o) { this.sel = o.id; this.close(); this.$refs.q.focus(); },\n         commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n       }\"\n       x-init=\"q = chosen ? chosen.name : ''\"\n       @click.outside=\"close()\"\n       @keydown.escape=\"if (open) { $event.stopPropagation(); close(); $refs.q.focus() }\">\n\n    <label for=\"{{ form.vendor.id_for_label }}-q\" class=\"mb-1.5 block text-[13px]/5 font-medium\">\n      {{ form.vendor.label }} <span class=\"text-red-600\">*</span>\n    </label>\n\n    <div class=\"flex items-center rounded-lg bg-white {% if form.vendor.errors %}border border-red-600 focus-within:ring-3 focus-within:ring-red-600/15{% else %}border border-zinc-200 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15{% endif %}\">\n      <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n      <!-- the search box has no name. Give it the field's name and the typed\n           text posts beside the id, and the form cleans whichever came last. -->\n      <input id=\"{{ form.vendor.id_for_label }}-q\" x-ref=\"q\" x-model=\"q\" type=\"text\"\n             role=\"combobox\" autocomplete=\"off\" aria-autocomplete=\"list\"\n             aria-controls=\"dj-vendor-list\"\n             {% if form.vendor.errors %}aria-invalid=\"true\" aria-describedby=\"dj-vendor-err\"{% endif %}\n             :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n             placeholder=\"Search vendors\"\n             @click=\"show()\"\n             @input=\"typed = true; open = true; ai = 0\"\n             @keydown.arrow-down.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-1)\"\n             @keydown.enter=\"if (open) { $event.preventDefault(); commit() }\"\n             @keydown.tab=\"close()\"\n             class=\"w-full min-w-0 bg-transparent px-2 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n    </div>\n\n    <!-- the field itself: one hidden input, named for the bound field -->\n    <input type=\"hidden\" name=\"{{ form.vendor.html_name }}\" :value=\"sel\">\n\n    {% if form.vendor.errors %}\n      <p id=\"dj-vendor-err\" class=\"mt-1.5 flex items-start gap-1.5 text-[12px]/4 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"mt-0.5 size-3.5 shrink-0\"></i>{{ form.vendor.errors.0 }}\n      </p>\n    {% endif %}\n\n    <p role=\"status\" class=\"sr-only\"\n       x-text=\"open ? (list.length === 1 ? '1 vendor matches' : list.length + ' vendors match') : ''\"></p>\n\n    <div x-show=\"open\" x-cloak\n         class=\"absolute top-full right-0 left-0 z-20 mt-1 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n      <div id=\"dj-vendor-list\" role=\"listbox\" aria-label=\"{{ form.vendor.label }}\" class=\"max-h-64 overflow-y-auto py-1\">\n        <template x-for=\"(o, i) in list\" :key=\"o.id\">\n          <div :id=\"rowId(o)\" role=\"option\" :aria-selected=\"String(o.id) === String(sel)\"\n               @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = i\"\n               :class=\"i === ai ? 'bg-zinc-100' : ''\"\n               class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n            <span class=\"min-w-0 flex-1 truncate\" x-text=\"o.name\"></span>\n            <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"o.code\"></span>\n          </div>\n        </template>\n      </div>\n      <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n        <p class=\"text-[13px]/5 font-medium\">No vendor matches “<span x-text=\"q\"></span>”</p>\n      </div>\n    </div>\n  </div>\n\n  <!-- The multiselect posts one hidden input per value, all sharing html_name,\n       which is what MultipleHiddenInput.value_from_datadict reads with getlist.\n       The initial selection comes out of the bound field through json_script\n       for the same escaping reason. The field and the popup are the multi\n       variant, unchanged. -->\n  <div class=\"mt-6\" x-data=\"{ sel: JSON.parse(document.getElementById('rfq-recipients').textContent) || [] }\">\n    <template x-for=\"id in sel\" :key=\"id\">\n      <input type=\"hidden\" name=\"{{ form.recipients.html_name }}\" :value=\"id\">\n    </template>\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"sel.length + ' vendors will receive this RFQ'\"></p>\n  </div>\n\n  <button type=\"submit\" class=\"mt-5 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Send RFQ</button>\n</form>\n\n<!-- rfq/_vendor_options.html — the whole response for the remote variant, and\n     nothing around it, because hx-swap is innerHTML. The ids come from the\n     server and still have to be unique: aria-activedescendant on the input\n     points at one of them. -->\n{% for v in hits %}\n  <div id=\"cb-remote-{{ v.pk }}\" role=\"option\" aria-selected=\"false\"\n       data-value=\"{{ v.pk }}\" data-label=\"{{ v.name }}\"\n       class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n    <span class=\"min-w-0 flex-1 truncate\">{{ v.name }}</span>\n    <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">{{ v.code }}</span>\n  </div>\n{% endfor %}"
        }
      ]
    },
    {
      "id": "attachment",
      "name": "Attachment",
      "category": "forms",
      "description": "The documents hanging off a record — a drop zone over the list of what is already there. Every file shows its type, size, who attached it and when.",
      "when_to_use": "Any record people staple paperwork to: quotations against an order, a signed GRN, a delivery photo, an invoice PDF. For a single file that is really just one field of a form, use the single-file variant rather than a whole panel.",
      "rules": [
        "Keep a real <input type=\"file\"> and hide it with sr-only, never display:none or hidden. A display:none input cannot be focused, so the keyboard can never reach the upload control at all.",
        "Make the label the peer of the hidden input and put the focus ring on the label with peer-focus-visible. Otherwise the input takes focus invisibly and the keyboard user has no idea where they are.",
        "Both @dragover.prevent and @drop.prevent are required. Without them the browser leaves the page and opens the dropped file, losing whatever was typed into the form.",
        "Count drag depth with dragenter/dragleave, do not use a boolean. dragleave fires every time the pointer crosses into a child element, so a boolean makes the highlight flicker as the file passes over the icon and the text.",
        "Never fake the progress bar. An animation that reaches 100% before the bytes do produces a user who navigates away mid-upload. Drive it from the real upload progress event, and if you cannot, show an indeterminate state instead of a number.",
        "Show the size and the type limit before the file is chosen, not as an error afterwards. \"PDF or JPG, up to 10 MB\" prevents the failure; \"File too large\" only reports it.",
        "A rejected file stays on screen with its reason. Silently dropping it means the user believes it uploaded.",
        "Removing a file that is already saved on the server is destructive and gets an alert dialog. Removing one that is still queued is not, and must not ask.",
        "The filename is the one thing users recognise, so it truncates and never wraps — truncate on a min-w-0 flex child, with the size and the controls shrink-0 beside it."
      ],
      "anatomy": [
        {
          "part": "Drop zone",
          "description": "A dashed panel wrapping the hidden input and its label. Carries the drag handlers and the type and size limit."
        },
        {
          "part": "Input",
          "description": "A real <input type=\"file\">, sr-only, and the peer of the label so focus is visible."
        },
        {
          "part": "File row",
          "description": "Icon, name, size, who and when, then the controls. The name flexes and truncates; everything else is shrink-0."
        },
        {
          "part": "State",
          "description": "A progress bar while uploading, a red reason line when rejected, nothing at all once it is stored."
        },
        {
          "part": "Controls",
          "description": "Download and remove. Remove is the only one that can be destructive."
        }
      ],
      "behaviour": [
        "Dragging a file over the zone highlights it; the highlight survives the pointer crossing child elements.",
        "Dropping or picking adds the file to the list immediately, in an uploading state, so the user sees it was accepted before it finishes.",
        "A file that fails validation joins the list too, marked with the reason, and does not count towards the upload.",
        "Progress reflects real bytes. Reaching 100% swaps the bar for the stored metadata line.",
        "Removing a queued file happens straight away; removing a stored one goes through an alert dialog first.",
        "The panel is useful with nothing in it — the empty state says what to attach and why, not just \"No files\"."
      ],
      "accessibility": [
        "The file input is a real input, focusable, and reachable by keyboard alone.",
        "The label is bound with for/id, so pressing Enter or Space on it opens the file picker.",
        "The focus ring is on the label via peer-focus-visible, because the input itself is visually hidden.",
        "Each remove button carries aria-label naming its file — five buttons all labelled \"Remove\" are useless in a list.",
        "Upload progress uses role=\"progressbar\" with aria-valuenow, so it is announced rather than only drawn.",
        "The rejection reason sits in the row it belongs to and is referenced by the row, not left as loose red text nearby."
      ],
      "related": [
        "input",
        "field",
        "alert-dialog"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Drop zone and list",
          "html": "<div x-data=\"{ depth: 0 }\">\n  <label class=\"mb-1.5 block text-[13px]/5 font-medium\">Attachments</label>\n\n  <div @dragenter.prevent=\"depth++\" @dragleave.prevent=\"depth--\" @dragover.prevent @drop.prevent=\"depth = 0\"\n       class=\"rounded-lg border border-dashed px-4 py-5 transition\"\n       :class=\"depth > 0 ? 'border-zinc-700 bg-zinc-50' : 'border-zinc-200 bg-zinc-100'\">\n    <div class=\"flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-center\">\n      <i data-lucide=\"upload\" class=\"size-5 shrink-0 text-zinc-600\"></i>\n      <span class=\"text-[13px]/5 text-zinc-600\">Drag files here or</span>\n      <input type=\"file\" id=\"po-files\" name=\"attachments\" multiple class=\"peer sr-only\">\n      <label for=\"po-files\"\n             class=\"cursor-pointer rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100 peer-focus-visible:border-zinc-700 peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/15\">\n        Browse files\n      </label>\n      <span class=\"text-[12px]/4 text-zinc-500\">PDF, JPG or XLSX · up to 10 MB each</span>\n    </div>\n  </div>\n\n  <ul class=\"mt-2 space-y-2\">\n    <li class=\"flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n      <div class=\"min-w-0 flex-1\">\n        <p class=\"truncate text-[13px]/5 font-medium\">quotation-sharma-extrusions-aug.pdf</p>\n        <p class=\"text-[12px]/4 text-zinc-500\">248 KB · Ritu Deshpande · 19 Aug 2024</p>\n      </div>\n      <a href=\"#\" aria-label=\"Download quotation-sharma-extrusions-aug.pdf\"\n         class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n        <i data-lucide=\"download\" class=\"size-4\"></i>\n      </a>\n      <button type=\"button\" aria-label=\"Remove quotation-sharma-extrusions-aug.pdf\"\n              class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-red-600\">\n        <i data-lucide=\"x\" class=\"size-4\"></i>\n      </button>\n    </li>\n    <li class=\"flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"sheet\" class=\"size-4 text-zinc-600\"></i></span>\n      <div class=\"min-w-0 flex-1\">\n        <p class=\"truncate text-[13px]/5 font-medium\">rate-comparison-q3.xlsx</p>\n        <p class=\"text-[12px]/4 text-zinc-500\">54 KB · Anil Kulkarni · 14 Aug 2024</p>\n      </div>\n      <a href=\"#\" aria-label=\"Download rate-comparison-q3.xlsx\"\n         class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n        <i data-lucide=\"download\" class=\"size-4\"></i>\n      </a>\n      <button type=\"button\" aria-label=\"Remove rate-comparison-q3.xlsx\"\n              class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-red-600\">\n        <i data-lucide=\"x\" class=\"size-4\"></i>\n      </button>\n    </li>\n  </ul>\n</div>"
        },
        {
          "id": "empty",
          "name": "Nothing attached",
          "html": "<!-- Say what to attach and why. \"No files\" is a fact, not an instruction. -->\n<div x-data=\"{ depth: 0 }\">\n  <label class=\"mb-1.5 block text-[13px]/5 font-medium\">Attachments</label>\n  <div @dragenter.prevent=\"depth++\" @dragleave.prevent=\"depth--\" @dragover.prevent @drop.prevent=\"depth = 0\"\n       class=\"rounded-lg border border-dashed px-4 py-8 text-center transition\"\n       :class=\"depth > 0 ? 'border-zinc-700 bg-zinc-50' : 'border-zinc-200 bg-zinc-100'\">\n    <i data-lucide=\"paperclip\" class=\"mx-auto size-6 text-zinc-500\"></i>\n    <p class=\"mt-3 text-[14px]/5 font-semibold\">No documents on this order yet</p>\n    <p class=\"mx-auto mt-1 max-w-[42ch] text-[12px]/4 text-zinc-600\">\n      Approvals above ₹10,00,000 need the vendor quotation attached before the plant head can sign off.\n    </p>\n    <input type=\"file\" id=\"empty-files\" name=\"attachments\" multiple class=\"peer sr-only\">\n    <label for=\"empty-files\"\n           class=\"mt-4 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800 peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/30\">\n      <i data-lucide=\"upload\" class=\"size-4\"></i>Attach a document\n    </label>\n    <p class=\"mt-3 text-[12px]/4 text-zinc-500\">PDF, JPG or XLSX · up to 10 MB each</p>\n  </div>\n</div>"
        },
        {
          "id": "uploading",
          "name": "Uploading",
          "html": "<!-- The number comes from the real XHR progress event. If you cannot get it,\n     drop the percentage and show an indeterminate bar instead of inventing one. -->\n<ul class=\"space-y-2\">\n  <li class=\"rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <div class=\"flex items-center gap-3\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n      <div class=\"min-w-0 flex-1\">\n        <p class=\"truncate text-[13px]/5 font-medium\">signed-grn-1142.pdf</p>\n        <p class=\"text-[12px]/4 text-zinc-500\">1.8 MB of 4.2 MB</p>\n      </div>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-600\">43%</span>\n      <button type=\"button\" aria-label=\"Cancel upload of signed-grn-1142.pdf\"\n              class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-red-600\">\n        <i data-lucide=\"x\" class=\"size-4\"></i>\n      </button>\n    </div>\n    <div role=\"progressbar\" aria-valuenow=\"43\" aria-valuemin=\"0\" aria-valuemax=\"100\"\n         aria-label=\"Uploading signed-grn-1142.pdf\"\n         class=\"mt-2 h-1 overflow-hidden rounded-full bg-zinc-100\">\n      <div class=\"h-full rounded-full bg-zinc-700 transition-[width] duration-300\" style=\"width: 43%\"></div>\n    </div>\n  </li>\n  <li class=\"flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"image\" class=\"size-4 text-zinc-600\"></i></span>\n    <div class=\"min-w-0 flex-1\">\n      <p class=\"truncate text-[13px]/5 font-medium\">delivery-gate-photo.jpg</p>\n      <p class=\"text-[12px]/4 text-zinc-500\">1.1 MB · Ritu Deshpande · just now</p>\n    </div>\n    <i data-lucide=\"check-circle-2\" class=\"size-4 shrink-0 text-emerald-600\"></i>\n  </li>\n</ul>"
        },
        {
          "id": "rejected",
          "name": "Rejected file",
          "html": "<!-- The rejected file stays visible with its reason. Removing it from the list\n     silently is how users end up believing something uploaded when it did not. -->\n<ul class=\"space-y-2\">\n  <li class=\"flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n    <div class=\"min-w-0 flex-1\">\n      <p class=\"truncate text-[13px]/5 font-medium\">quotation-sharma-extrusions-aug.pdf</p>\n      <p class=\"text-[12px]/4 text-zinc-500\">248 KB · Ritu Deshpande · 19 Aug 2024</p>\n    </div>\n    <button type=\"button\" aria-label=\"Remove quotation-sharma-extrusions-aug.pdf\"\n            class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-red-600\">\n      <i data-lucide=\"x\" class=\"size-4\"></i>\n    </button>\n  </li>\n  <li class=\"flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file\" class=\"size-4 text-zinc-500\"></i></span>\n    <div class=\"min-w-0 flex-1\">\n      <p class=\"truncate text-[13px]/5 font-medium text-zinc-600 line-through\">plant-layout-revised.dwg</p>\n      <p id=\"reject-dwg\" class=\"mt-0.5 flex items-center gap-1.5 text-[12px]/4 font-medium text-red-600\">\n        <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>\n        DWG is not accepted. Attach a PDF export instead.\n      </p>\n    </div>\n    <button type=\"button\" aria-label=\"Dismiss plant-layout-revised.dwg\" aria-describedby=\"reject-dwg\"\n            class=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900\">\n      <i data-lucide=\"x\" class=\"size-4\"></i>\n    </button>\n  </li>\n</ul>"
        },
        {
          "id": "readonly",
          "name": "Read only",
          "html": "<!-- No upload rights. Do not render a disabled drop zone — remove it, and let\n     the list stand on its own. -->\n<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3\">\n    <h3 class=\"text-[13px]/5 font-medium\">Attachments</h3>\n    <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">3 files · 2.4 MB</span>\n  </div>\n  <ul class=\"divide-y divide-zinc-100\">\n    <li class=\"flex items-center gap-3 px-4 py-2.5\">\n      <i data-lucide=\"file-text\" class=\"size-4 shrink-0 text-zinc-500\"></i>\n      <a href=\"#\" class=\"min-w-0 flex-1 truncate text-[13px]/5 text-zinc-900 underline underline-offset-2\">quotation-sharma-extrusions-aug.pdf</a>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">248 KB</span>\n    </li>\n    <li class=\"flex items-center gap-3 px-4 py-2.5\">\n      <i data-lucide=\"sheet\" class=\"size-4 shrink-0 text-zinc-500\"></i>\n      <a href=\"#\" class=\"min-w-0 flex-1 truncate text-[13px]/5 text-zinc-900 underline underline-offset-2\">rate-comparison-q3.xlsx</a>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">54 KB</span>\n    </li>\n    <li class=\"flex items-center gap-3 px-4 py-2.5\">\n      <i data-lucide=\"image\" class=\"size-4 shrink-0 text-zinc-500\"></i>\n      <a href=\"#\" class=\"min-w-0 flex-1 truncate text-[13px]/5 text-zinc-900 underline underline-offset-2\">delivery-gate-photo.jpg</a>\n      <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">2.1 MB</span>\n    </li>\n  </ul>\n</div>"
        },
        {
          "id": "single",
          "name": "Single file field",
          "html": "<!-- One file, inside a form, where a whole panel would be out of proportion. -->\n<div>\n  <label for=\"grn-scan\" class=\"mb-1.5 block text-[13px]/5 font-medium\">\n    Signed GRN <span class=\"text-red-600\">*</span>\n  </label>\n  <div class=\"flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n    <div class=\"min-w-0 flex-1\">\n      <p class=\"truncate text-[13px]/5 font-medium\">signed-grn-1142.pdf</p>\n      <p class=\"text-[12px]/4 text-zinc-500\">4.2 MB · attached just now</p>\n    </div>\n    <input type=\"file\" id=\"grn-scan\" name=\"grn_scan\" class=\"peer sr-only\">\n    <label for=\"grn-scan\"\n           class=\"shrink-0 cursor-pointer rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100 peer-focus-visible:border-zinc-700 peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/15\">\n      Replace\n    </label>\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">PDF or JPG, up to 10 MB.</p>\n</div>"
        },
        {
          "id": "images",
          "name": "Image grid",
          "html": "<!-- Photographs are recognised by sight, not by filename. Show them. -->\n<div>\n  <div class=\"flex items-end justify-between gap-3\">\n    <label class=\"text-[13px]/5 font-medium\">Delivery photographs</label>\n    <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">4 of 10</span>\n  </div>\n  <div class=\"mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4\">\n    <div class=\"group relative aspect-4/3 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100\">\n      <div class=\"flex h-full items-center justify-center\"><i data-lucide=\"image\" class=\"size-5 text-zinc-500\"></i></div>\n      <button type=\"button\" aria-label=\"Remove gate-in-truck.jpg\"\n              class=\"absolute right-1 top-1 flex size-6 items-center justify-center rounded-md bg-white/90 text-zinc-600 opacity-0 transition hover:text-red-600 group-hover:opacity-100 focus-visible:opacity-100\">\n        <i data-lucide=\"x\" class=\"size-3.5\"></i>\n      </button>\n      <p class=\"absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-zinc-900/70 to-transparent px-2 pb-1 pt-4 text-[11px]/4 text-white\">gate-in-truck.jpg</p>\n    </div>\n    <div class=\"group relative aspect-4/3 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100\">\n      <div class=\"flex h-full items-center justify-center\"><i data-lucide=\"image\" class=\"size-5 text-zinc-500\"></i></div>\n      <button type=\"button\" aria-label=\"Remove unloading-bay-3.jpg\"\n              class=\"absolute right-1 top-1 flex size-6 items-center justify-center rounded-md bg-white/90 text-zinc-600 opacity-0 transition hover:text-red-600 group-hover:opacity-100 focus-visible:opacity-100\">\n        <i data-lucide=\"x\" class=\"size-3.5\"></i>\n      </button>\n      <p class=\"absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-zinc-900/70 to-transparent px-2 pb-1 pt-4 text-[11px]/4 text-white\">unloading-bay-3.jpg</p>\n    </div>\n    <div class=\"group relative aspect-4/3 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100\">\n      <div class=\"flex h-full items-center justify-center\"><i data-lucide=\"image\" class=\"size-5 text-zinc-500\"></i></div>\n      <button type=\"button\" aria-label=\"Remove weighbridge-slip.jpg\"\n              class=\"absolute right-1 top-1 flex size-6 items-center justify-center rounded-md bg-white/90 text-zinc-600 opacity-0 transition hover:text-red-600 group-hover:opacity-100 focus-visible:opacity-100\">\n        <i data-lucide=\"x\" class=\"size-3.5\"></i>\n      </button>\n      <p class=\"absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-zinc-900/70 to-transparent px-2 pb-1 pt-4 text-[11px]/4 text-white\">weighbridge-slip.jpg</p>\n    </div>\n    <div>\n      <input type=\"file\" id=\"photo-add\" name=\"photos\" accept=\"image/*\" multiple class=\"peer sr-only\">\n      <label for=\"photo-add\"\n             class=\"flex aspect-4/3 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-zinc-200 bg-zinc-100 text-zinc-600 hover:border-zinc-700 hover:bg-zinc-50 peer-focus-visible:border-zinc-700 peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/15\">\n        <i data-lucide=\"plus\" class=\"size-5\"></i>\n        <span class=\"text-[12px]/4 font-medium\">Add photo</span>\n      </label>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django ClearableFileInput",
          "html": "<!-- Drop-in replacement for {{ form.attachment }} when the field is a FileField.\n     Django's ClearableFileInput renders exactly three things: the current file\n     link, a clear checkbox named <field>-clear, and the new file input. Keep all\n     three names or the POST does not clear the file.\n\n     {% if form.attachment.value %} … {% endif %} guards the first two. -->\n<div>\n  <label for=\"id_attachment\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Attachment</label>\n\n  <div class=\"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-zinc-200 bg-white px-3 py-2\">\n    <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n    <a href=\"/media/po/quotation-sharma-aug.pdf\" class=\"min-w-0 flex-1 truncate text-[13px]/5 text-zinc-900 underline underline-offset-2\">quotation-sharma-aug.pdf</a>\n    <label class=\"flex shrink-0 items-center gap-2 text-[12px]/4 text-zinc-600\">\n      <input type=\"checkbox\" name=\"attachment-clear\" id=\"attachment-clear_id\"\n             class=\"size-4 rounded border-zinc-200 text-zinc-700 focus:ring-zinc-700/15\">\n      Clear\n    </label>\n  </div>\n\n  <div class=\"mt-2\">\n    <input type=\"file\" name=\"attachment\" id=\"id_attachment\" class=\"peer sr-only\">\n    <label for=\"id_attachment\"\n           class=\"inline-flex cursor-pointer items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100 peer-focus-visible:border-zinc-700 peer-focus-visible:ring-3 peer-focus-visible:ring-zinc-700/15\">\n      <i data-lucide=\"upload\" class=\"size-4\"></i>Choose a replacement\n    </label>\n  </div>\n\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Uploading a new file replaces the current one.</p>\n  <p class=\"mt-1.5 flex items-center gap-1.5 text-[12px]/4 font-medium text-red-600\">\n    <i data-lucide=\"alert-circle\" class=\"size-3.5 shrink-0\"></i>\n    Remember enctype=\"multipart/form-data\" on the &lt;form&gt;, or request.FILES arrives empty.\n  </p>\n</div>"
        }
      ]
    },
    {
      "id": "calendar",
      "name": "Calendar",
      "category": "forms",
      "description": "A month grid for picking a date or a range. Monday-first, tabular figures, and the same graphite selection the rest of the system uses.",
      "when_to_use": "Choosing a date the user has to see in context — a delivery date next to the weekend, a range that spans a month end. For a date of birth or anything typed from memory, an input is faster than a grid.",
      "rules": [
        "The week starts on Monday. Every Indian office runs Monday to Saturday, and a Sunday-first grid puts the weekend at both ends of the row where nobody reads it.",
        "Days are tabular-nums inside a fixed square, so the columns line up and 1 sits under 8 sits under 15. Proportional figures make the grid lean.",
        "Leading and trailing cells are blank, not greyed-out neighbouring dates. A muted date is still a date: it invites a click, and at the contrast it needs to read as muted it fails AA anyway.",
        "Today is the chip treatment — bg-zinc-200 with ring-zinc-300 — and the selected day is solid zinc-700. Today is a fact about the calendar; selection is a fact about the form, and the solid fill outranks the tint.",
        "The grid is one Tab stop. Arrows move by day, Up and Down by week, PageUp and PageDown by month, Home and End to the ends of the week. Without that a month is 31 tab stops.",
        "Changing month carries the focused day with it. The roving tabindex sits on one date, so if the view moves and the focus does not, the month on screen contains no tabbable day and the grid drops out of the Tab order entirely — measured at zero tab stops after paging away.",
        "A range is two values and one grid. Never two separate single pickers side by side — the user can then set an end before its start, and something has to reject it after the fact."
      ],
      "anatomy": [
        {
          "part": "Header",
          "description": "Month and year, with a previous and next control either side. The label is aria-live so changing month is announced, not just drawn."
        },
        {
          "part": "Column headers",
          "description": "Two-letter weekday names, Mo through Su, as real th scope=\"col\"."
        },
        {
          "part": "Day cell",
          "description": "A size-9 square button carrying the full date as its accessible name — \"Friday, 14 August 2026\", not \"14\"."
        },
        {
          "part": "Today",
          "description": "bg-zinc-200 with ring-zinc-300 and aria-current=\"date\"."
        },
        {
          "part": "Selection",
          "description": "Solid zinc-700. In a range, the two endpoints are solid and the days between take a zinc-200 band behind them."
        },
        {
          "part": "Presets",
          "description": "A column beside the grid for the ranges people actually pick — this month, last 30 days, this financial year."
        }
      ],
      "behaviour": [
        "Clicking a day selects it. In a range, the first click sets the start, the second sets the end, and a third starts over.",
        "Picking an end date before the start swaps them rather than refusing — the user has said which two days they mean.",
        "Arrow keys move focus and month, so arrowing off the end of August lands on 1 September with the view following.",
        "A picker closes on selection, on Escape, and on a click outside; a range picker stays open until both ends are set.",
        "Disabled dates are skipped by nothing — they keep their place in the grid so the shape of the month does not change, but they cannot be focused or clicked.",
        "The financial year preset runs April to March, because that is what the year means in an Indian ledger."
      ],
      "accessibility": [
        "The grid is a real table with th scope=\"col\" for the weekdays, so row and column position is available without sight.",
        "Each day button is named with its full date. \"14\" on its own is meaningless when read out of the grid.",
        "Today carries aria-current=\"date\" and the selected day aria-pressed, which are different facts and both worth announcing.",
        "The month label is aria-live=\"polite\", so moving month is announced rather than silently redrawn.",
        "A roving tabindex makes the whole month one Tab stop; only the focused day is reachable with Tab.",
        "Disabled days are real disabled buttons, so they are skipped by the keyboard and announced as unavailable."
      ],
      "related": [
        "input",
        "field",
        "button"
      ],
      "variants": [
        {
          "id": "month",
          "name": "Month grid",
          "html": "<!-- The primitive. Everything else here is this grid with something round it.\n\n     Nothing stores a Date. State is a year, a month and ISO strings; every Date\n     is built inside a method and thrown away. Alpine wraps state in a reactive\n     Proxy, and a proxied Date throws \"Illegal invocation\" the moment you call\n     getDate() on it — the proxy forwards properties, not a Date's internal\n     slots. weeks() therefore hands the template plain { iso, day, dow } objects\n     so no template expression ever touches a Date method.\n\n     today is assembled from getFullYear/getMonth/getDate, not from\n     toISOString(), which is UTC — in IST that reports yesterday until 05:30.\n\n     Monday first: (getDay() + 6) % 7 moves Sunday from 0 to 6, which is where\n     the weekend belongs in an office that works Saturdays. -->\n<div class=\"inline-block rounded-xl border border-zinc-200 bg-white p-3\"\n     x-data=\"{\n       focus: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       sel: null,\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       move(n) {\n         const f = this.parse(this.focus);\n         f.setDate(f.getDate() + n);\n         this.focus = this.iso(f.getFullYear(), f.getMonth(), f.getDate());\n         this.vy = f.getFullYear(); this.vm = f.getMonth();\n         this.$nextTick(() => { const b = this.$refs.grid.querySelector('[data-focus]'); if (b) b.focus(); });\n       },\n       cls(s) {\n         if (s === this.sel) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'bg-zinc-200 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n     }\">\n  <div class=\"flex items-center justify-between pb-2\">\n    <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n    <span class=\"text-[13px]/5 font-medium tabular-nums\" aria-live=\"polite\" x-text=\"title(0)\"></span>\n    <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n  <table x-ref=\"grid\" class=\"border-collapse\"\n           @keydown.arrow-left.prevent=\"move(-1)\"  @keydown.arrow-right.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-7)\"    @keydown.arrow-down.prevent=\"move(7)\"\n             @keydown.home.prevent=\"move(-((parse(focus).getDay() + 6) % 7))\"\n             @keydown.end.prevent=\"move(6 - ((parse(focus).getDay() + 6) % 7))\"\n             @keydown.page-up.prevent=\"shift(-1)\"    @keydown.page-down.prevent=\"shift(1)\">\n    <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(0)\"></caption>\n    <thead>\n      <tr>\n        <template x-for=\"h in head\" :key=\"h\">\n          <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n        </template>\n      </tr>\n    </thead>\n    <tbody>\n      <template x-for=\"(week, wi) in weeks(0)\" :key=\"wi\">\n        <tr>\n          <template x-for=\"(c, ci) in week\" :key=\"ci\">\n            <td class=\"p-0\">\n              <template x-if=\"c\">\n                <button type=\"button\" @click=\"sel = c.iso; focus = c.iso\"\n                        :aria-label=\"long(c.iso)\" :aria-pressed=\"c.iso === sel\"\n                        :aria-current=\"c.iso === today ? 'date' : null\"\n                        :tabindex=\"c.iso === focus ? 0 : -1\"\n                        :data-focus=\"c.iso === focus ? '1' : null\"\n                        class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                        :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n              </template>\n              <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n            </td>\n          </template>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "picker",
          "name": "Date picker",
          "html": "<!-- A field that opens the grid. The trigger shows the date the way it is read\n     — 14 Aug 2026 — while a hidden input carries the ISO string that posts, so\n     the display format and the wire format never have to agree.\n\n     x-trap keeps Tab inside the panel and returns focus to the trigger on\n     close. Choosing a day closes it, because a single date is finished the\n     moment it is picked. -->\n<div class=\"relative max-w-xs\"\n     x-data=\"{\n       open: false,\n       focus: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       sel: null,\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       move(n) {\n         const f = this.parse(this.focus);\n         f.setDate(f.getDate() + n);\n         this.focus = this.iso(f.getFullYear(), f.getMonth(), f.getDate());\n         this.vy = f.getFullYear(); this.vm = f.getMonth();\n         this.$nextTick(() => { const b = this.$refs.grid.querySelector('[data-focus]'); if (b) b.focus(); });\n       },\n       pick(s) { this.sel = s; this.focus = s; this.open = false; this.$nextTick(() => this.$refs.trigger.focus()); },\n       cls(s) {\n         if (s === this.sel) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'bg-zinc-200 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n     }\" @click.outside=\"open = false\" @keydown.escape.window=\"open = false\">\n  <span id=\"due-label\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Due date</span>\n  <input type=\"hidden\" name=\"due_date\" :value=\"sel\">\n\n  <button type=\"button\" x-ref=\"trigger\" @click=\"open = !open\"\n          aria-labelledby=\"due-label\" :aria-expanded=\"open\" aria-haspopup=\"dialog\"\n          class=\"inline-flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 hover:bg-zinc-100\">\n    <span class=\"tabular-nums\" :class=\"sel ? 'text-zinc-900' : 'text-zinc-500'\" x-text=\"sel ? fmt(sel) : 'Select a date'\"></span>\n    <i data-lucide=\"calendar\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap=\"open\" role=\"dialog\" aria-label=\"Choose a due date\"\n       class=\"absolute left-0 z-40 mt-1 rounded-xl border border-zinc-200 bg-white p-3 shadow-lg\">\n    <div class=\"flex items-center justify-between pb-2\">\n      <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n              class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n        <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n      </button>\n      <span class=\"text-[13px]/5 font-medium tabular-nums\" aria-live=\"polite\" x-text=\"title(0)\"></span>\n      <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n              class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n        <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n      </button>\n    </div>\n    <table x-ref=\"grid\" class=\"border-collapse\"\n             @keydown.arrow-left.prevent=\"move(-1)\"  @keydown.arrow-right.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-7)\"    @keydown.arrow-down.prevent=\"move(7)\"\n             @keydown.home.prevent=\"move(-((parse(focus).getDay() + 6) % 7))\"\n             @keydown.end.prevent=\"move(6 - ((parse(focus).getDay() + 6) % 7))\"\n             @keydown.page-up.prevent=\"shift(-1)\"    @keydown.page-down.prevent=\"shift(1)\">\n      <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(0)\"></caption>\n      <thead>\n        <tr>\n          <template x-for=\"h in head\" :key=\"h\">\n            <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n          </template>\n        </tr>\n      </thead>\n      <tbody>\n        <template x-for=\"(week, wi) in weeks(0)\" :key=\"wi\">\n          <tr>\n            <template x-for=\"(c, ci) in week\" :key=\"ci\">\n              <td class=\"p-0\">\n                <template x-if=\"c\">\n                  <button type=\"button\" @click=\"pick(c.iso)\"\n                          :aria-label=\"long(c.iso)\" :aria-pressed=\"c.iso === sel\"\n                          :aria-current=\"c.iso === today ? 'date' : null\"\n                          :tabindex=\"c.iso === focus ? 0 : -1\"\n                          :data-focus=\"c.iso === focus ? '1' : null\"\n                          class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                          :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n                </template>\n                <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n              </td>\n            </template>\n          </tr>\n        </template>\n      </tbody>\n    </table>\n    <div class=\"mt-2 flex items-center justify-between border-t border-zinc-200 pt-2\">\n      <button type=\"button\" @click=\"sel = null\" class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear</button>\n      <button type=\"button\" @click=\"vy = new Date().getFullYear(); vm = new Date().getMonth(); pick(today)\"\n              class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Today</button>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "range",
          "name": "Date range",
          "html": "<!-- Two months, one state. A range is two values on one grid, never two single\n     pickers side by side — that lets someone set an end before its start and\n     leaves something else to reject it afterwards.\n\n     First click sets the start, second the end, third starts over. An end\n     before its start swaps them rather than refusing: the user has said which\n     two days they mean and the order is an implementation detail.\n\n     The band sits on the td and the endpoints on the button, so one continuous\n     stripe runs behind the row while the two ends stay round. Today keeps only\n     a ring here, not a fill — a zinc-200 chip inside a zinc-200 band is\n     invisible. -->\n<div class=\"inline-block rounded-xl border border-zinc-200 bg-white p-3\"\n     x-data=\"{\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       start: null, end: null, hover: null,\n       pick(s) {\n         if (!this.start || (this.start && this.end)) { this.start = s; this.end = null; return; }\n         if (s < this.start) { this.end = this.start; this.start = s; } else { this.end = s; }\n         this.hover = null;\n       },\n       tail() { return this.end || this.hover; },\n       isEnd(s) { return s === this.start || s === this.end; },\n       span() { return (this.start && this.end) ? Math.round((this.parse(this.end) - this.parse(this.start)) / 86400000) + 1 : 0; },\n       band(s) {\n         const t = this.tail();\n         if (!this.start || !t || this.start === t) return '';\n         const lo = this.start < t ? this.start : t, hi = this.start < t ? t : this.start;\n         if (s < lo || s > hi) return '';\n         if (s === lo) return 'bg-zinc-200 rounded-l-lg';\n         if (s === hi) return 'bg-zinc-200 rounded-r-lg';\n         return 'bg-zinc-200';\n       },\n       cls(s) {\n         if (this.isEnd(s)) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'font-medium text-zinc-900 ring-1 ring-inset ring-zinc-400 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n     }\">\n  <div class=\"flex items-center justify-between pb-2\">\n    <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n    <span class=\"text-[13px]/5 font-medium tabular-nums\" aria-live=\"polite\" x-text=\"fmt(start) + ' – ' + fmt(end)\"></span>\n    <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n  <div class=\"flex flex-col gap-5 lg:flex-row\" @mouseleave=\"hover = null\">\n    <template x-for=\"o in [0, 1]\" :key=\"o\">\n      <div :class=\"o === 1 && 'hidden lg:block'\">\n        <p class=\"pb-1 text-center text-[12px]/4 font-medium text-zinc-600\" x-text=\"title(o)\"></p>\n        <table class=\"border-collapse\">\n          <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(o)\"></caption>\n          <thead>\n            <tr>\n              <template x-for=\"h in head\" :key=\"h\">\n                <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n              </template>\n            </tr>\n          </thead>\n          <tbody>\n            <template x-for=\"(week, wi) in weeks(o)\" :key=\"wi\">\n              <tr>\n                <template x-for=\"(c, ci) in week\" :key=\"ci\">\n                  <td class=\"p-0\" :class=\"c && band(c.iso)\">\n                    <template x-if=\"c\">\n                      <button type=\"button\" @click=\"pick(c.iso)\" @mouseenter=\"if (start && !end) hover = c.iso\"\n                              :aria-label=\"long(c.iso)\" :aria-pressed=\"isEnd(c.iso)\"\n                              :aria-current=\"c.iso === today ? 'date' : null\"\n                              class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                              :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n                    </template>\n                    <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n                  </td>\n                </template>\n              </tr>\n            </template>\n          </tbody>\n        </table>\n      </div>\n    </template>\n  </div>\n  <div class=\"mt-3 flex items-center justify-between border-t border-zinc-200 pt-2\">\n    <span class=\"text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"span() ? span() + ' days' : 'Pick a start and an end'\"></span>\n    <button type=\"button\" @click=\"start = null; end = null; hover = null\"\n            class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear</button>\n  </div>\n</div>"
        },
        {
          "id": "jump",
          "name": "Jumping to a distant month",
          "html": "<!-- Chevrons are fine for next week and useless for March 2019 — that is 84\n     clicks. The header doubles as two selects, so any month in a twelve-year\n     window is one gesture.\n\n     The year list is built around the current year rather than hard-coded, so\n     this markup does not quietly expire. -->\n<div class=\"inline-block rounded-xl border border-zinc-200 bg-white p-3\"\n     x-data=\"{\n       focus: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       sel: null,\n       months: ['January','February','March','April','May','June','July','August','September','October','November','December'],\n       years: (() => { const y = new Date().getFullYear(); const a = []; for (let i = y - 8; i <= y + 3; i++) a.push(i); return a; })(),\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       move(n) {\n         const f = this.parse(this.focus);\n         f.setDate(f.getDate() + n);\n         this.focus = this.iso(f.getFullYear(), f.getMonth(), f.getDate());\n         this.vy = f.getFullYear(); this.vm = f.getMonth();\n         this.$nextTick(() => { const b = this.$refs.grid.querySelector('[data-focus]'); if (b) b.focus(); });\n       },\n       cls(s) {\n         if (s === this.sel) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'bg-zinc-200 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n     }\">\n  <div class=\"flex items-center gap-1.5 pb-2\">\n    <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n            class=\"inline-flex size-8 shrink-0 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n\n    <label for=\"cal-month\" class=\"sr-only\">Month</label>\n    <select id=\"cal-month\" x-model.number=\"vm\" @change=\"$nextTick(() => clamp())\"\n            class=\"h-8 min-w-0 flex-1 rounded-lg border border-zinc-200 bg-white px-2 text-[13px]/5 font-medium outline-none focus:border-zinc-700 focus:ring-3 focus:ring-zinc-700/15\">\n      <template x-for=\"(m, i) in months\" :key=\"m\">\n        <option :value=\"i\" x-text=\"m\"></option>\n      </template>\n    </select>\n\n    <label for=\"cal-year\" class=\"sr-only\">Year</label>\n    <select id=\"cal-year\" x-model.number=\"vy\" @change=\"$nextTick(() => clamp())\"\n            class=\"h-8 shrink-0 rounded-lg border border-zinc-200 bg-white px-2 text-[13px]/5 font-medium tabular-nums outline-none focus:border-zinc-700 focus:ring-3 focus:ring-zinc-700/15\">\n      <template x-for=\"y in years\" :key=\"y\">\n        <option :value=\"y\" x-text=\"y\"></option>\n      </template>\n    </select>\n\n    <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n            class=\"inline-flex size-8 shrink-0 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n  <table x-ref=\"grid\" class=\"border-collapse\"\n           @keydown.arrow-left.prevent=\"move(-1)\"  @keydown.arrow-right.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-7)\"    @keydown.arrow-down.prevent=\"move(7)\"\n             @keydown.page-up.prevent=\"shift(-1)\"    @keydown.page-down.prevent=\"shift(1)\">\n    <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(0)\"></caption>\n    <thead>\n      <tr>\n        <template x-for=\"h in head\" :key=\"h\">\n          <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n        </template>\n      </tr>\n    </thead>\n    <tbody>\n      <template x-for=\"(week, wi) in weeks(0)\" :key=\"wi\">\n        <tr>\n          <template x-for=\"(c, ci) in week\" :key=\"ci\">\n            <td class=\"p-0\">\n              <template x-if=\"c\">\n                <button type=\"button\" @click=\"sel = c.iso; focus = c.iso\"\n                        :aria-label=\"long(c.iso)\" :aria-pressed=\"c.iso === sel\"\n                        :aria-current=\"c.iso === today ? 'date' : null\"\n                        :tabindex=\"c.iso === focus ? 0 : -1\"\n                        :data-focus=\"c.iso === focus ? '1' : null\"\n                        class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                        :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n              </template>\n              <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n            </td>\n          </template>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</div>"
        },
        {
          "id": "constrained",
          "name": "Limits and blocked dates",
          "html": "<!-- A delivery date: not in the past, not more than 90 days out, and not on a\n     Sunday or a plant holiday. The rules live in one predicate, so the grid,\n     the keyboard and any later validation cannot disagree about them.\n\n     Blocked days keep their square. Removing them would reshape the month and\n     move every date after them into a different column.\n\n     why() puts the reason into the accessible name, because a disabled button\n     announces \"unavailable\" and never says why. -->\n<div class=\"inline-block rounded-xl border border-zinc-200 bg-white p-3\"\n     x-data=\"{\n       focus: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       sel: null,\n       holidays: ['2026-10-02', '2026-11-08', '2026-12-25'],\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       move(n) {\n         const f = this.parse(this.focus);\n         f.setDate(f.getDate() + n);\n         this.focus = this.iso(f.getFullYear(), f.getMonth(), f.getDate());\n         this.vy = f.getFullYear(); this.vm = f.getMonth();\n         this.$nextTick(() => { const b = this.$refs.grid.querySelector('[data-focus]'); if (b) b.focus(); });\n       },\n       max: (() => { const t = new Date(); t.setDate(t.getDate() + 90); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       blocked(s, dow) { return s < this.today || s > this.max || dow === 0 || this.holidays.includes(s); },\n       why(s, dow) {\n         if (s < this.today) return ' — in the past';\n         if (s > this.max) return ' — beyond the 90 day window';\n         if (dow === 0) return ' — Sunday, plant closed';\n         if (this.holidays.includes(s)) return ' — plant holiday';\n         return '';\n       },\n       cls(s) {\n         if (s === this.sel) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'bg-zinc-200 font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n     }\">\n  <div class=\"flex items-center justify-between pb-2\">\n    <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n    <span class=\"text-[13px]/5 font-medium tabular-nums\" aria-live=\"polite\" x-text=\"title(0)\"></span>\n    <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n            class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n  <table x-ref=\"grid\" class=\"border-collapse\"\n           @keydown.arrow-left.prevent=\"move(-1)\"  @keydown.arrow-right.prevent=\"move(1)\"\n             @keydown.arrow-up.prevent=\"move(-7)\"    @keydown.arrow-down.prevent=\"move(7)\"\n             @keydown.page-up.prevent=\"shift(-1)\"    @keydown.page-down.prevent=\"shift(1)\">\n    <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(0)\"></caption>\n    <thead>\n      <tr>\n        <template x-for=\"h in head\" :key=\"h\">\n          <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n        </template>\n      </tr>\n    </thead>\n    <tbody>\n      <template x-for=\"(week, wi) in weeks(0)\" :key=\"wi\">\n        <tr>\n          <template x-for=\"(c, ci) in week\" :key=\"ci\">\n            <td class=\"p-0\">\n              <template x-if=\"c\">\n                <button type=\"button\" @click=\"sel = c.iso; focus = c.iso\"\n                        :disabled=\"blocked(c.iso, c.dow)\"\n                        :aria-label=\"long(c.iso) + why(c.iso, c.dow)\" :aria-pressed=\"c.iso === sel\"\n                        :aria-current=\"c.iso === today ? 'date' : null\"\n                        :tabindex=\"c.iso === focus ? 0 : -1\"\n                        :data-focus=\"c.iso === focus ? '1' : null\"\n                        class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                        :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n              </template>\n              <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n            </td>\n          </template>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n  <p class=\"mt-2 max-w-[17rem] text-[12px]/4 text-zinc-500\">Sundays and plant holidays are closed. Delivery can be booked up to 90 days ahead.</p>\n</div>"
        },
        {
          "id": "presets",
          "name": "Range with presets",
          "html": "<!-- Nobody clicks their way to \"last 30 days\". The preset column is what gets\n     used; the grid is there for the once a month it is not enough.\n\n     The financial year runs April to March, so before April \"This FY\" starts in\n     the previous calendar year — m < 3 is the whole rule. Getting that wrong is\n     a bug that only appears in Q1 and only to accounts.\n\n     Picking a day in the grid clears the preset label, because the range is no\n     longer the thing the preset named. -->\n<div class=\"relative inline-block\"\n     x-data=\"{\n       open: false,\n       vy: new Date().getFullYear(),\n       vm: new Date().getMonth(),\n       today: (() => { const t = new Date(); return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0'); })(),\n       head: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],\n       iso(y, m, d) { return y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); },\n       parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n       title(o) { return new Date(this.vy, this.vm + o, 1).toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); },\n       long(s) { return this.parse(s).toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); },\n       fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n       weeks(o) {\n         const y = this.vy, m = this.vm + o;\n         const lead = (new Date(y, m, 1).getDay() + 6) % 7;\n         const total = new Date(y, m + 1, 0).getDate();\n         const cells = [];\n         for (let i = 0; i < lead; i++) cells.push(null);\n         for (let d = 1; d <= total; d++) {\n           const dt = new Date(y, m, d);\n           cells.push({ iso: this.iso(dt.getFullYear(), dt.getMonth(), d), day: d, dow: dt.getDay() });\n         }\n         while (cells.length % 7) cells.push(null);\n         const out = [];\n         for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7));\n         return out;\n       },\n       shift(n) { const v = new Date(this.vy, this.vm + n, 1); this.vy = v.getFullYear(); this.vm = v.getMonth(); this.clamp(); },\n       clamp() {\n         if (!this.focus) return;\n         const f = this.parse(this.focus);\n         if (f.getFullYear() === this.vy && f.getMonth() === this.vm) return;\n         const last = new Date(this.vy, this.vm + 1, 0).getDate();\n         this.focus = this.iso(this.vy, this.vm, Math.min(f.getDate(), last));\n       },\n       start: null, end: null, hover: null,\n       pick(s) {\n         if (!this.start || (this.start && this.end)) { this.start = s; this.end = null; return; }\n         if (s < this.start) { this.end = this.start; this.start = s; } else { this.end = s; }\n         this.hover = null;\n       },\n       tail() { return this.end || this.hover; },\n       isEnd(s) { return s === this.start || s === this.end; },\n       span() { return (this.start && this.end) ? Math.round((this.parse(this.end) - this.parse(this.start)) / 86400000) + 1 : 0; },\n       band(s) {\n         const t = this.tail();\n         if (!this.start || !t || this.start === t) return '';\n         const lo = this.start < t ? this.start : t, hi = this.start < t ? t : this.start;\n         if (s < lo || s > hi) return '';\n         if (s === lo) return 'bg-zinc-200 rounded-l-lg';\n         if (s === hi) return 'bg-zinc-200 rounded-r-lg';\n         return 'bg-zinc-200';\n       },\n       cls(s) {\n         if (this.isEnd(s)) return 'bg-zinc-700 font-medium text-white hover:bg-zinc-800';\n         if (s === this.today) return 'font-medium text-zinc-900 ring-1 ring-inset ring-zinc-400 hover:bg-zinc-300';\n         return 'text-zinc-900 hover:bg-zinc-200';\n       }\n       ,\n       presets: ['Today', 'Last 7 days', 'Last 30 days', 'This month', 'Last month', 'This FY'],\n       preset: null,\n       apply(name) {\n         const t = new Date(), y = t.getFullYear(), m = t.getMonth(), d = t.getDate();\n         let a, b;\n         if (name === 'Today')        { a = new Date(y, m, d); b = new Date(y, m, d); }\n         if (name === 'Last 7 days')  { a = new Date(y, m, d - 6); b = new Date(y, m, d); }\n         if (name === 'Last 30 days') { a = new Date(y, m, d - 29); b = new Date(y, m, d); }\n         if (name === 'This month')   { a = new Date(y, m, 1); b = new Date(y, m + 1, 0); }\n         if (name === 'Last month')   { a = new Date(y, m - 1, 1); b = new Date(y, m, 0); }\n         if (name === 'This FY')      { a = new Date(m < 3 ? y - 1 : y, 3, 1); b = new Date(y, m, d); }\n         this.start = this.iso(a.getFullYear(), a.getMonth(), a.getDate());\n         this.end = this.iso(b.getFullYear(), b.getMonth(), b.getDate());\n         this.preset = name;\n         this.vy = a.getFullYear(); this.vm = a.getMonth();\n       },\n       tap(s) { this.preset = null; this.pick(s); }\n     }\" @click.outside=\"open = false\" @keydown.escape.window=\"open = false\">\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-haspopup=\"dialog\"\n          class=\"inline-flex h-9 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"calendar-range\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"tabular-nums\" x-text=\"preset || (start ? fmt(start) + ' – ' + fmt(end) : 'Select period')\"></span>\n    <i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-600\"></i>\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap=\"open\" role=\"dialog\" aria-label=\"Choose a reporting period\"\n       class=\"absolute left-0 z-40 mt-1 flex flex-col rounded-xl border border-zinc-200 bg-white shadow-lg sm:flex-row\">\n    <div class=\"flex shrink-0 gap-1 overflow-x-auto border-b border-zinc-200 p-2 sm:w-44 sm:flex-col sm:gap-0.5 sm:overflow-visible sm:border-r sm:border-b-0\">\n      <template x-for=\"p in presets\" :key=\"p\">\n        <button type=\"button\" @click=\"apply(p)\" :aria-pressed=\"preset === p\"\n                class=\"inline-flex h-8 shrink-0 items-center rounded-lg px-3 text-left text-[13px]/5 whitespace-nowrap\"\n                :class=\"preset === p ? 'bg-zinc-700 font-medium text-white' : 'text-zinc-900 hover:bg-zinc-200'\">\n          <span x-text=\"p\"></span>\n        </button>\n      </template>\n    </div>\n\n    <div class=\"p-3\">\n      <div class=\"flex items-center justify-between pb-2\">\n        <button type=\"button\" @click=\"shift(-1)\" aria-label=\"Previous month\"\n                class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n          <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n        </button>\n        <span class=\"text-[13px]/5 font-medium tabular-nums\" aria-live=\"polite\" x-text=\"fmt(start) + ' – ' + fmt(end)\"></span>\n        <button type=\"button\" @click=\"shift(1)\" aria-label=\"Next month\"\n                class=\"inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">\n          <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n        </button>\n      </div>\n      <div class=\"flex flex-col gap-5 lg:flex-row\" @mouseleave=\"hover = null\">\n        <template x-for=\"o in [0, 1]\" :key=\"o\">\n          <div :class=\"o === 1 && 'hidden lg:block'\">\n            <p class=\"pb-1 text-center text-[12px]/4 font-medium text-zinc-600\" x-text=\"title(o)\"></p>\n            <table class=\"border-collapse\">\n              <caption class=\"sr-only\" x-text=\"'Calendar, ' + title(o)\"></caption>\n              <thead>\n                <tr>\n                  <template x-for=\"h in head\" :key=\"h\">\n                    <th scope=\"col\" class=\"size-9 text-[11px]/4 font-medium text-zinc-500\" x-text=\"h\"></th>\n                  </template>\n                </tr>\n              </thead>\n              <tbody>\n                <template x-for=\"(week, wi) in weeks(o)\" :key=\"wi\">\n                  <tr>\n                    <template x-for=\"(c, ci) in week\" :key=\"ci\">\n                      <td class=\"p-0\" :class=\"c && band(c.iso)\">\n                        <template x-if=\"c\">\n                          <button type=\"button\" @click=\"tap(c.iso)\" @mouseenter=\"if (start && !end) hover = c.iso\"\n                                  :aria-label=\"long(c.iso)\" :aria-pressed=\"isEnd(c.iso)\"\n                                  :aria-current=\"c.iso === today ? 'date' : null\"\n                                  class=\"inline-flex size-9 items-center justify-center rounded-lg text-[13px]/5 tabular-nums\"\n                                  :class=\"cls(c.iso)\" x-text=\"c.day\"></button>\n                        </template>\n                        <template x-if=\"!c\"><span class=\"block size-9\"></span></template>\n                      </td>\n                    </template>\n                  </tr>\n                </template>\n              </tbody>\n            </table>\n          </div>\n        </template>\n      </div>\n      <div class=\"mt-3 flex items-center justify-between gap-3 border-t border-zinc-200 pt-3\">\n        <span class=\"text-[12px]/4 tabular-nums text-zinc-500\" x-text=\"span() ? span() + ' days' : 'No period set'\"></span>\n        <div class=\"flex items-center gap-2\">\n          <button type=\"button\" @click=\"start = null; end = null; preset = null\"\n                  class=\"inline-flex h-8 items-center rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">Clear</button>\n          <button type=\"button\" @click=\"open = false\" :disabled=\"!start || !end\"\n                  class=\"inline-flex h-8 items-center rounded-lg border border-transparent bg-zinc-700 px-3 text-[13px]/5 font-medium text-white hover:bg-zinc-800 disabled:bg-zinc-200 disabled:text-zinc-400\">Apply</button>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "native",
          "name": "The native input",
          "html": "<!-- Reach for this first. On a phone it opens the OS date wheel, which beats\n     any grid rendered in a page; it validates, it respects the device locale,\n     and it posts an ISO string that Django parses with no format setting at all.\n\n     Use the grid instead when the choice depends on seeing the month — a\n     delivery date next to the weekend, a range across a month end. Use this\n     when the user already knows the date. -->\n<div class=\"max-w-xs\">\n  <label for=\"grn-date\" class=\"mb-1.5 block text-[13px]/5 font-medium\">GRN date</label>\n  <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n    <input type=\"date\" id=\"grn-date\" name=\"grn_date\" value=\"2026-08-14\" max=\"2026-12-31\"\n           class=\"h-9 w-full bg-transparent px-3 text-[14px]/5 tabular-nums outline-none\">\n  </div>\n  <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Cannot be later than the invoice date.</p>\n</div>"
        },
        {
          "id": "django",
          "name": "Django form fields",
          "html": "<!-- Two named inputs, one clean(). The values that post are ISO strings, which\n     is the one format DateField parses without touching DATE_INPUT_FORMATS.\n\n     # forms.py\n     class PeriodForm(forms.Form):\n         start = forms.DateField(input_formats=['%Y-%m-%d'])\n         end   = forms.DateField(input_formats=['%Y-%m-%d'])\n\n         def clean(self):\n             c = super().clean()\n             if c.get('start') and c.get('end') and c['start'] > c['end']:\n                 raise ValidationError('The period ends before it starts.')\n             return c\n\n     The clean() check is not optional even though the widget prevents it — a\n     POST does not have to come from this page. :min on the end input is a\n     convenience for the user, never a guarantee to the view. -->\n<form method=\"get\" class=\"max-w-md\"\n      x-data=\"{\n        start: '2026-04-01', end: '2026-08-20',\n        parse(s) { const p = s.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },\n        fmt(s) { return s ? this.parse(s).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; },\n        span() { return (this.start && this.end) ? Math.round((this.parse(this.end) - this.parse(this.start)) / 86400000) + 1 : 0; }\n      }\">\n  <fieldset>\n    <legend class=\"mb-1.5 text-[13px]/5 font-medium\">Reporting period</legend>\n\n    <div class=\"flex items-center gap-2\">\n      <div class=\"flex-1 rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n        <label for=\"id_start\" class=\"sr-only\">Start date</label>\n        <input type=\"date\" id=\"id_start\" name=\"start\" x-model=\"start\"\n               class=\"h-9 w-full bg-transparent px-3 text-[14px]/5 tabular-nums outline-none\">\n      </div>\n      <span class=\"text-[13px]/5 text-zinc-500\" aria-hidden=\"true\">to</span>\n      <div class=\"flex-1 rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n        <label for=\"id_end\" class=\"sr-only\">End date</label>\n        <input type=\"date\" id=\"id_end\" name=\"end\" x-model=\"end\" :min=\"start\"\n               class=\"h-9 w-full bg-transparent px-3 text-[14px]/5 tabular-nums outline-none\">\n      </div>\n    </div>\n\n    <p class=\"mt-1.5 text-[12px]/4 tabular-nums text-zinc-500\" aria-live=\"polite\"\n       x-text=\"span() ? fmt(start) + ' to ' + fmt(end) + ' · ' + span() + ' days' : 'Both dates are required.'\"></p>\n  </fieldset>\n\n  <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Run report</button>\n</form>"
        }
      ]
    },
    {
      "id": "app-shell",
      "name": "App shell",
      "category": "layout",
      "description": "The page skeleton: collapsible sidebar, topbar, and a single scrolling main column. Everything else in the app is rendered inside the main column.",
      "when_to_use": "Every signed-in page. Auth and error pages are the only screens that do not use it.",
      "rules": [
        "The collapse control puts its icon in a fixed 68px box at the left of a full-width strip, so the icon centre is at the same coordinates expanded and collapsed. Do not centre the icon in the strip — the second click misses when the sidebar narrows.",
        "The collapsed rail is 68px so a 40px hit area still has 14px of padding either side. Labels hide with lg:hidden, they are not removed from the DOM.",
        "Rotation goes on a wrapping <span>, never on <i data-lucide>. Lucide replaces the <i> with an <svg> and any binding on it dies.",
        "Sidebar state persists under the localStorage key kon-sidebar. The [ shortcut is ignored while focus is in an input, textarea or select.",
        "Only the <main> scrolls. The shell root is h-[640px] here so it previews in a box — as a real page put h-screen overflow-hidden on <body> and drop the wrapper border.",
        "Below lg the sidebar is off-canvas with a backdrop; the topbar menu button is the only way to open it, so never hide that button on mobile."
      ],
      "anatomy": [
        {
          "part": "Sidebar",
          "description": "255px expanded, a 68px rail collapsed. Off-canvas with a backdrop below lg."
        },
        {
          "part": "Collapse control",
          "description": "A full-width strip whose icon sits in a fixed 68px box at the left, so the aim point does not move between states."
        },
        {
          "part": "Topbar",
          "description": "Search, notifications, account, and the menu button that is the only way to open the sidebar on a phone."
        },
        {
          "part": "Main",
          "description": "The single scrolling column. Everything else in the application renders inside it."
        },
        {
          "part": "Backdrop",
          "description": "Below lg only, dimming the main column while the off-canvas sidebar is open."
        }
      ],
      "behaviour": [
        "Only <main> scrolls. The shell itself is a fixed-height flex frame, so the sidebar and topbar never move.",
        "The collapse control keeps its icon at identical coordinates in both states — a control that moves out from under the cursor feels broken even when every click lands.",
        "Collapsed labels hide with lg:hidden rather than being removed, so the DOM and the tab order do not change shape.",
        "Sidebar state persists under the localStorage key kon-sidebar, so it survives a reload.",
        "The [ shortcut toggles the sidebar, and is ignored while focus is in an input, textarea or select.",
        "Below lg the sidebar is off-canvas over a backdrop, which is why the topbar menu button must never be hidden on mobile."
      ],
      "accessibility": [
        "The sidebar is a <nav> with an accessible name, so it can be skipped as a landmark.",
        "The collapse control is a real button with aria-expanded reflecting the state.",
        "Collapsed items keep an accessible name even with the label visually hidden, or the rail becomes a column of unnamed icons.",
        "The off-canvas sidebar traps focus while open on mobile and returns it to the menu button on close.",
        "A skip link to <main> comes first in the DOM, so the keyboard is not walked through the whole nav on every page."
      ],
      "related": [
        "sidebar-nav",
        "topbar",
        "page-header"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"relative flex h-[640px] overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 text-[14px]/5 text-zinc-900\"\n     x-data=\"{ sidebar: true, nav: false }\"\n     x-init=\"sidebar = localStorage.getItem('kon-sidebar') !== '0';\n             $watch('sidebar', v => localStorage.setItem('kon-sidebar', v ? '1' : '0'))\"\n     @keydown.escape.window=\"nav = false\"\n     @keydown.window=\"if ($event.key === '[' && !/^(input|textarea|select)$/i.test($event.target.tagName)) sidebar = !sidebar\">\n\n  <!-- sidebar -->\n  <aside class=\"absolute inset-y-0 left-0 z-40 flex shrink-0 flex-col border-r border-zinc-200 bg-white transition-all duration-200 lg:static lg:translate-x-0\"\n         :class=\"[ sidebar ? 'w-64' : 'w-64 lg:w-[68px]', nav ? 'translate-x-0' : '-translate-x-full' ]\">\n\n    <div class=\"flex h-14 shrink-0 items-center gap-2.5 px-4\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-700 text-[13px]/5 font-semibold text-white\">K</span>\n      <span class=\"min-w-0 flex-1 truncate text-[16px]/6 font-semibold\" :class=\"!sidebar && 'lg:hidden'\">Konspec Operations</span>\n      <button @click=\"nav = false\" aria-label=\"Close navigation\" class=\"rounded-md p-1 text-zinc-600 hover:bg-zinc-100 lg:hidden\">\n        <i data-lucide=\"x\" class=\"size-4\"></i>\n      </button>\n    </div>\n\n    <div class=\"px-3 pb-3\">\n      <button class=\"flex w-full items-center gap-2.5 rounded-lg bg-zinc-700 px-3 py-2.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\"\n              :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"plus\" class=\"size-4 shrink-0\"></i><span :class=\"!sidebar && 'lg:hidden'\">New purchase order</span>\n      </button>\n    </div>\n\n    <nav class=\"min-h-0 flex-1 space-y-0.5 overflow-y-auto px-3\">\n      <p class=\"px-2 pb-1 pt-2 text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\" :class=\"!sidebar && 'lg:hidden'\">Procurement</p>\n\n      <a href=\"#\" aria-current=\"page\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg bg-zinc-100 px-2.5 py-2 text-[13px]/5 font-medium text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"layout-dashboard\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Overview</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Overview</span>\n      </a>\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"file-text\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Purchase orders</span>\n        <span class=\"rounded-full bg-white px-1.5 text-[11px]/4 tabular-nums text-zinc-600 ring-1 ring-zinc-200\" :class=\"!sidebar && 'lg:hidden'\">148</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Purchase orders</span>\n      </a>\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"clipboard-list\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Requisitions</span>\n        <span class=\"rounded-full bg-white px-1.5 text-[11px]/4 tabular-nums text-zinc-600 ring-1 ring-zinc-200\" :class=\"!sidebar && 'lg:hidden'\">62</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Requisitions</span>\n      </a>\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"truck\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Goods receipt</span>\n        <span class=\"rounded-full bg-white px-1.5 text-[11px]/4 tabular-nums text-zinc-600 ring-1 ring-zinc-200\" :class=\"!sidebar && 'lg:hidden'\">27</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Goods receipt</span>\n      </a>\n\n      <p class=\"px-2 pb-1 pt-4 text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\" :class=\"!sidebar && 'lg:hidden'\">Master data</p>\n\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"building-2\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Vendors</span>\n        <span class=\"rounded-full bg-white px-1.5 text-[11px]/4 tabular-nums text-zinc-600 ring-1 ring-zinc-200\" :class=\"!sidebar && 'lg:hidden'\">187</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Vendors</span>\n      </a>\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"package\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Materials</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Materials</span>\n      </a>\n      <a href=\"#\" class=\"group relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 py-2 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\"\n         :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <i data-lucide=\"scale\" class=\"size-[18px] shrink-0\"></i>\n        <span class=\"flex-1 truncate\" :class=\"!sidebar && 'lg:hidden'\">Rate contracts</span>\n        <span x-show=\"!sidebar\" class=\"pointer-events-none absolute left-14 z-50 hidden whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px]/4 text-white group-hover:lg:block\">Rate contracts</span>\n      </a>\n    </nav>\n\n    <div class=\"shrink-0 border-t border-zinc-200\">\n      <button class=\"flex w-full items-center gap-2.5 px-3 py-3 text-left hover:bg-zinc-100\" :class=\"!sidebar && 'lg:justify-center lg:px-0'\">\n        <span class=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[11px]/4 font-semibold text-zinc-900\">AP</span>\n        <span class=\"min-w-0 flex-1\" :class=\"!sidebar && 'lg:hidden'\">\n          <span class=\"block truncate text-[13px]/5 font-medium\">Akshay Prabhu</span>\n          <span class=\"block truncate text-[11px]/4 text-zinc-500\">Level 2 approver</span>\n        </span>\n      </button>\n\n      <!-- the icon sits in a 68px box, so its centre does not move when the rail narrows -->\n      <button @click=\"sidebar = !sidebar\" aria-label=\"Toggle sidebar\"\n              :title=\"sidebar ? 'Collapse sidebar  [' : 'Expand sidebar  ['\"\n              class=\"hidden h-11 w-full items-center border-t border-zinc-200 text-[13px]/5 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 lg:flex\">\n        <span class=\"flex h-11 w-[68px] shrink-0 items-center justify-center\">\n          <span class=\"flex transition-transform\" :class=\"!sidebar && 'rotate-180'\"><i data-lucide=\"chevrons-left\" class=\"size-4\"></i></span>\n        </span>\n        <span class=\"flex-1 text-left\" :class=\"!sidebar && 'lg:hidden'\">Collapse</span>\n        <kbd class=\"mr-3 rounded border border-zinc-200 bg-zinc-100 px-1.5 text-[11px]/4 text-zinc-600\" :class=\"!sidebar && 'lg:hidden'\">[</kbd>\n      </button>\n    </div>\n  </aside>\n\n  <div x-show=\"nav\" x-cloak @click=\"nav = false\" class=\"absolute inset-0 z-30 bg-zinc-900/40 lg:hidden\"></div>\n\n  <!-- main column -->\n  <div class=\"flex min-w-0 flex-1 flex-col\">\n    <header class=\"flex h-14 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white px-3 lg:px-5\">\n      <button @click=\"nav = true\" aria-label=\"Open navigation\"\n              class=\"-ml-1 flex size-10 shrink-0 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 lg:hidden\">\n        <i data-lucide=\"menu\" class=\"size-5\"></i>\n      </button>\n      <nav aria-label=\"Breadcrumb\" class=\"hidden items-center gap-1.5 text-[13px]/5 text-zinc-600 sm:flex\">\n        <a href=\"#\" class=\"hover:text-zinc-900\">Procurement</a><span class=\"text-zinc-500\">/</span><span class=\"font-medium text-zinc-900\">Overview</span>\n      </nav>\n      <div class=\"ml-auto flex items-center gap-2\">\n        <div class=\"hidden items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15 md:flex\">\n          <i data-lucide=\"search\" class=\"size-4 text-zinc-600\"></i>\n          <input aria-label=\"Search\" placeholder=\"Search orders, vendors, materials\" class=\"w-40 bg-transparent text-[13px]/5 outline-none placeholder:text-zinc-500 xl:w-64\">\n        </div>\n        <button aria-label=\"Notifications\" class=\"relative rounded-lg p-2 text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n          <i data-lucide=\"bell\" class=\"size-[18px]\"></i>\n          <span class=\"absolute right-1.5 top-1.5 size-2 rounded-full bg-red-600 ring-2 ring-white\"></span>\n        </button>\n        <button class=\"flex size-8 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[11px]/4 font-semibold text-zinc-900\">AP</button>\n      </div>\n    </header>\n\n    <main class=\"min-h-0 flex-1 overflow-auto\">\n      <div class=\"mx-auto max-w-[1600px] space-y-4 p-4 pb-16 lg:p-6\">\n        <div>\n          <h1 class=\"text-[24px]/7 font-semibold tracking-tight\">Procurement overview</h1>\n          <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Live commitments, receipts and vendor performance · FY 2026–27</p>\n        </div>\n        <div class=\"grid gap-3 sm:grid-cols-3\">\n          <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n            <p class=\"text-[13px]/5 text-zinc-600\">Open commitment</p>\n            <p class=\"mt-2 text-[24px]/7 font-semibold tracking-tight tabular-nums\">₹1,66,40,000</p>\n            <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Across 148 purchase orders</p>\n          </div>\n          <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n            <p class=\"text-[13px]/5 text-zinc-600\">Awaiting GRN</p>\n            <p class=\"mt-2 text-[24px]/7 font-semibold tracking-tight tabular-nums\">27</p>\n            <p class=\"mt-2 text-[12px]/4 text-zinc-500\">9 past the promised date</p>\n          </div>\n          <div class=\"rounded-xl border border-zinc-200 bg-white p-4\">\n            <p class=\"text-[13px]/5 text-zinc-600\">Pending my approval</p>\n            <p class=\"mt-2 text-[24px]/7 font-semibold tracking-tight tabular-nums\">4</p>\n            <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Oldest raised 3 days ago</p>\n          </div>\n        </div>\n      </div>\n    </main>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "page-header",
      "name": "Page header",
      "category": "layout",
      "description": "The top block of a page: where it sits in the app, what it is, and what you can do to it. One h1 per page and it lives here.",
      "when_to_use": "Every page inside the app shell. Skip the breadcrumb only on top-level pages that have no parent.",
      "rules": [
        "Exactly one primary button. Everything else is secondary or a menu.",
        "Actions wrap below the title on narrow screens — never scroll them sideways or hide them behind a menu on mobile only.",
        "Tabs change what is shown on the same record. If a tab loads a different record, it is navigation and belongs in the sidebar."
      ],
      "anatomy": [
        {
          "part": "Breadcrumb",
          "description": "Where this page sits. Omitted only on top-level pages that have no parent."
        },
        {
          "part": "Title",
          "description": "The one h1 on the page. It lives here and nowhere else."
        },
        {
          "part": "Meta",
          "description": "Status, reference number, amount — what identifies this record at a glance."
        },
        {
          "part": "Actions",
          "description": "Exactly one primary button; everything else secondary or inside a menu."
        },
        {
          "part": "Tabs",
          "description": "Optional, and only for views of the same record. A tab that loads a different record is navigation."
        }
      ],
      "behaviour": [
        "One h1 per page, and this is it. Cards below use h2 and h3.",
        "Actions wrap below the title on narrow screens. They are never scrolled sideways and never hidden behind a menu on mobile only.",
        "Tabs change what is shown about the same record. If a tab loads a different record it belongs in the sidebar.",
        "The header does not stick. On a long record the actions are repeated at the bottom of the form instead.",
        "Meta values use tabular-nums so a header does not shift as the record's amount changes."
      ],
      "accessibility": [
        "The title is a real h1, giving the page a single unambiguous top-level heading.",
        "The breadcrumb is a <nav> with aria-label=\"Breadcrumb\" and marks the current page with aria-current.",
        "Tabs are a tablist with roles and arrow-key movement, not a row of styled links.",
        "The action cluster reads in the same order it is drawn, so the primary action is not announced first by accident.",
        "Status in the meta line is text as well as colour."
      ],
      "related": [
        "breadcrumbs",
        "tabs",
        "app-shell"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"flex flex-wrap items-end justify-between gap-4\">\n  <div class=\"min-w-0\">\n    <nav aria-label=\"Breadcrumb\" class=\"flex items-center gap-1.5 text-[12px]/4 text-zinc-600\">\n      <a href=\"#\" class=\"hover:text-zinc-900\">Procurement</a>\n      <span class=\"text-zinc-500\">/</span>\n      <a href=\"#\" class=\"hover:text-zinc-900\">Purchase orders</a>\n      <span class=\"text-zinc-500\">/</span>\n      <span class=\"font-medium text-zinc-900\">PO-24-1187</span>\n    </nav>\n    <h1 class=\"mt-1.5 text-[24px]/7 font-semibold tracking-tight\">Sharma Steel &amp; Alloys</h1>\n    <p class=\"mt-1 text-[13px]/5 text-zinc-600\">PO-24-1187 · raised 04 Aug 2026 by Akshay Prabhu · Fabrication</p>\n  </div>\n  <div class=\"flex flex-wrap items-center gap-2\">\n    <button class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"printer\" class=\"size-4\"></i>Print\n    </button>\n    <button class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"pencil\" class=\"size-4\"></i>Amend\n    </button>\n    <button class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-3.5 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n      <i data-lucide=\"truck\" class=\"size-4\"></i>Record GRN\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "tabs",
          "name": "With tabs",
          "html": "<div class=\"border-b border-zinc-200\" x-data=\"{ tab: 'lines' }\">\n  <div class=\"flex flex-wrap items-end justify-between gap-4\">\n    <div class=\"min-w-0\">\n      <nav aria-label=\"Breadcrumb\" class=\"flex items-center gap-1.5 text-[12px]/4 text-zinc-600\">\n        <a href=\"#\" class=\"hover:text-zinc-900\">Master data</a>\n        <span class=\"text-zinc-500\">/</span>\n        <span class=\"font-medium text-zinc-900\">Gujarat Polymers Ltd</span>\n      </nav>\n      <h1 class=\"mt-1.5 flex items-center gap-2.5 text-[24px]/7 font-semibold tracking-tight\">\n        Gujarat Polymers Ltd\n        <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700\">\n          <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Approved vendor\n        </span>\n      </h1>\n      <p class=\"mt-1 text-[13px]/5 text-zinc-600\">V-0412 · GSTIN 24AACCG1234F1ZP · Vapi, Gujarat</p>\n    </div>\n    <button class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-3.5 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n      <i data-lucide=\"plus\" class=\"size-4\"></i>New order\n    </button>\n  </div>\n\n  <div class=\"-mb-px mt-4 flex gap-1 overflow-x-auto\">\n    <button @click=\"tab = 'lines'\" class=\"shrink-0 border-b-2 px-3 py-2.5 text-[13px]/5\"\n            :class=\"tab === 'lines' ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\">Open orders</button>\n    <button @click=\"tab = 'rates'\" class=\"flex shrink-0 items-center gap-2 border-b-2 px-3 py-2.5 text-[13px]/5\"\n            :class=\"tab === 'rates' ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\">\n      Rate contracts <span class=\"rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-1.5 text-[11px]/4 tabular-nums text-zinc-700\">6</span>\n    </button>\n    <button @click=\"tab = 'grn'\" class=\"shrink-0 border-b-2 px-3 py-2.5 text-[13px]/5\"\n            :class=\"tab === 'grn' ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\">Receipts</button>\n    <button @click=\"tab = 'docs'\" class=\"shrink-0 border-b-2 px-3 py-2.5 text-[13px]/5\"\n            :class=\"tab === 'docs' ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\">Documents</button>\n  </div>\n</div>"
        },
        {
          "id": "filters",
          "name": "With filters and count",
          "html": "<div>\n  <div class=\"flex flex-wrap items-end justify-between gap-4\">\n    <div class=\"min-w-0\">\n      <h1 class=\"text-[24px]/7 font-semibold tracking-tight\">Purchase orders</h1>\n      <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Every order raised against a Konspec plant or project</p>\n    </div>\n    <div class=\"flex flex-wrap items-center gap-2\">\n      <button class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n        <i data-lucide=\"download\" class=\"size-4\"></i>Export\n      </button>\n      <button class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-3.5 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n        <i data-lucide=\"plus\" class=\"size-4\"></i>New order\n      </button>\n    </div>\n  </div>\n\n  <div class=\"mt-4 flex flex-wrap items-center gap-2\">\n    <span class=\"flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 py-1 pl-3 pr-1.5 text-[12px]/4 font-medium\">\n      Status: Overdue\n      <button aria-label=\"Remove status filter\" class=\"rounded-full p-0.5 text-zinc-600 hover:bg-white hover:text-zinc-900\"><i data-lucide=\"x\" class=\"size-3\"></i></button>\n    </span>\n    <span class=\"flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 py-1 pl-3 pr-1.5 text-[12px]/4 font-medium\">\n      Plant: Silvassa\n      <button aria-label=\"Remove plant filter\" class=\"rounded-full p-0.5 text-zinc-600 hover:bg-white hover:text-zinc-900\"><i data-lucide=\"x\" class=\"size-3\"></i></button>\n    </span>\n    <span class=\"flex items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 py-1 pl-3 pr-1.5 text-[12px]/4 font-medium\">\n      Value over ₹5,00,000\n      <button aria-label=\"Remove value filter\" class=\"rounded-full p-0.5 text-zinc-600 hover:bg-white hover:text-zinc-900\"><i data-lucide=\"x\" class=\"size-3\"></i></button>\n    </span>\n    <button class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear all</button>\n    <span class=\"ml-auto text-[12px]/4 tabular-nums text-zinc-600\">34 of 148 orders · ₹2,18,64,500</span>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "card",
      "name": "Card",
      "category": "layout",
      "description": "A bordered white panel that groups one thing. A header names it, the body holds the content, and an optional footer carries totals or actions.",
      "when_to_use": "Grouping related content on a page. Do not nest cards — if a card needs sections inside it, those are dividers.",
      "rules": [
        "Panels are rounded-xl; controls inside them stay rounded-lg. A card inside a card is always a layout mistake.",
        "Header is px-5 py-3.5, body px-5 py-4, footer px-5 py-3. The header is tighter than the body on purpose: it holds one line of 20px text, and py-4 makes it taller than the content it names.",
        "A card holding a table takes no body padding at all. The table sits flush so its rules meet the card's edges; padding leaves the header row floating inside a border it does not touch.",
        "Shadows are for things floating over the page — menus, popovers, modals. A card sitting on the page uses a border, never a shadow.",
        "overflow-hidden on the panel only when nothing inside it needs to escape. A card containing a dropdown or a popover must round its own header and footer instead, or the menu is clipped.",
        "Cards in a row size to their content. items-start, not items-stretch — equal heights across a row leave dead space that reads as missing data."
      ],
      "anatomy": [
        {
          "part": "Panel",
          "description": "rounded-xl, white, zinc-200 border. Never a shadow — shadows mean the thing is floating above the page."
        },
        {
          "part": "Header",
          "description": "px-5 py-3.5 on a bordered strip: the title, an optional one-line subtitle, and any header action."
        },
        {
          "part": "Title",
          "description": "A heading at the level the page outline implies — usually h2 or h3, never chosen for its size."
        },
        {
          "part": "Body",
          "description": "px-5 py-4, or no padding at all when the content is a table or a divided list."
        },
        {
          "part": "Divider",
          "description": "border-zinc-100 between sections inside a card that already has a zinc-200 border of its own."
        },
        {
          "part": "Footer",
          "description": "px-5 py-3 on a zinc-100 strip above a top border, for totals or the actions that finish the card."
        }
      ],
      "behaviour": [
        "Cards do not nest. If a card needs sections inside it, those are dividers.",
        "Panels are rounded-xl and controls inside them stay rounded-lg, so the shapes stay in a hierarchy.",
        "A card holding a table drops its body padding entirely, or the table's rules stop meeting the card's edges.",
        "The header stays present even when the card holds one thing — an unnamed panel is hard to refer to.",
        "Card height is driven by content. Forcing equal heights across a row leaves dead space that reads as missing data.",
        "A card that is entirely clickable is one link around the whole panel, and then it contains no other links or buttons — a control inside a link is unreachable by keyboard and ambiguous by mouse."
      ],
      "accessibility": [
        "The card title is a heading at the level the page outline implies, usually h2 or h3.",
        "A card is a section, not a landmark, so it needs no role — the heading is what makes it navigable.",
        "A clickable card makes the whole panel the link rather than only the title, so the target is the size of the card.",
        "A clickable card holds no nested links or buttons; if it needs a second action, the card is not the link and the title is.",
        "Dividers are borders, not <hr>, so they are not announced as separators inside a list of fields.",
        "Footer actions sit inside the card in the DOM as well as visually, so their context is clear when read out of order."
      ],
      "related": [
        "stat-card",
        "table",
        "separator"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n    <h2 class=\"text-[14px]/5 font-semibold\">Delivery terms</h2>\n  </div>\n  <div class=\"px-5 py-4\">\n    <dl class=\"grid gap-x-6 gap-y-3 sm:grid-cols-2\">\n      <div>\n        <dt class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Incoterm</dt>\n        <dd class=\"mt-0.5 text-[14px]/5\">FOR Silvassa plant</dd>\n      </div>\n      <div>\n        <dt class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Lead time</dt>\n        <dd class=\"mt-0.5 text-[14px]/5 tabular-nums\">14 days from PO release</dd>\n      </div>\n      <div>\n        <dt class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Payment</dt>\n        <dd class=\"mt-0.5 text-[14px]/5\">45 days from GRN</dd>\n      </div>\n      <div>\n        <dt class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Freight</dt>\n        <dd class=\"mt-0.5 text-[14px]/5\">Vendor scope, included in rate</dd>\n      </div>\n    </dl>\n  </div>\n</div>"
        },
        {
          "id": "actions",
          "name": "With header actions",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-5 py-3.5\">\n    <div class=\"min-w-0\">\n      <h2 class=\"text-[14px]/5 font-semibold\">Attached documents</h2>\n      <p class=\"text-[12px]/4 text-zinc-600\">Quotation, drawing and test certificate</p>\n    </div>\n    <div class=\"flex items-center gap-2\">\n      <button class=\"inline-flex h-8 items-center rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">Download all</button>\n      <button class=\"inline-flex h-8 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-3 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n        <i data-lucide=\"upload\" class=\"size-4\"></i>Attach\n      </button>\n    </div>\n  </div>\n  <ul class=\"divide-y divide-zinc-100\">\n    <li class=\"flex items-center gap-3 px-5 py-3\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\"><i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i></span>\n      <span class=\"min-w-0 flex-1\">\n        <span class=\"block truncate text-[13px]/5 font-medium\">quotation-sharma-aug.pdf</span>\n        <span class=\"block text-[11px]/4 text-zinc-500\">248 KB · uploaded 04 Aug by Akshay Prabhu</span>\n      </span>\n      <a href=\"#\" class=\"text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">View</a>\n    </li>\n    <li class=\"flex items-center gap-3 px-5 py-3\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\"><i data-lucide=\"ruler\" class=\"size-4 text-zinc-600\"></i></span>\n      <span class=\"min-w-0 flex-1\">\n        <span class=\"block truncate text-[13px]/5 font-medium\">drg-fab-2211-rev-c.pdf</span>\n        <span class=\"block text-[11px]/4 text-zinc-500\">1.4 MB · uploaded 05 Aug by Nilesh Patil</span>\n      </span>\n      <a href=\"#\" class=\"text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">View</a>\n    </li>\n  </ul>\n</div>"
        },
        {
          "id": "stats",
          "name": "Split stats footer",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n    <h2 class=\"text-[14px]/5 font-semibold\">Order value</h2>\n    <p class=\"text-[12px]/4 text-zinc-600\">PO-24-1187 · 9 lines · Sharma Steel &amp; Alloys</p>\n  </div>\n  <div class=\"px-5 py-4\">\n    <p class=\"text-[24px]/7 font-semibold tracking-tight tabular-nums\">₹18,42,000</p>\n    <p class=\"mt-2 text-[12px]/4 text-zinc-600\">Inclusive of 18% GST · freight in vendor scope</p>\n    <div class=\"mt-4\">\n      <div class=\"flex items-center justify-between text-[12px]/4\"><span class=\"text-zinc-600\">Received against this order</span><span class=\"font-medium tabular-nums\">62%</span></div>\n      <div class=\"mt-2 h-2 overflow-hidden rounded-full bg-zinc-100\"><div class=\"h-full rounded-full bg-zinc-700\" style=\"width:62%\"></div></div>\n    </div>\n  </div>\n  <div class=\"grid grid-cols-3 divide-x divide-zinc-200 border-t border-zinc-200 text-center\">\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Received</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹11,42,040</p>\n    </div>\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Invoiced</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹9,80,000</p>\n    </div>\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Balance</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹6,99,960</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "section",
          "name": "Section card",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white px-5 py-5\">\n  <h2 class=\"text-[16px]/6 font-semibold\">Approval policy</h2>\n  <p class=\"mt-1 max-w-prose text-[13px]/5 text-zinc-600\">\n    Who has to sign off before an order is released to the vendor. Changes apply to orders raised from tomorrow onward.\n  </p>\n  <div class=\"mt-4 space-y-3 border-t border-zinc-100 pt-4\">\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" checked class=\"mt-0.5 size-4 rounded accent-zinc-700\">\n      <span>Require a second approver above ₹5,00,000\n        <span class=\"mt-0.5 block text-[12px]/4 text-zinc-500\">Currently routed to Nilesh Patil, Head of Procurement</span>\n      </span>\n    </label>\n    <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n      <input type=\"checkbox\" checked class=\"mt-0.5 size-4 rounded accent-zinc-700\">\n      <span>Block release when the vendor has no active rate contract\n        <span class=\"mt-0.5 block text-[12px]/4 text-zinc-500\">14 vendors would be affected today</span>\n      </span>\n    </label>\n  </div>\n</div>"
        },
        {
          "id": "table",
          "name": "Holding a table",
          "html": "<!-- The one case where the body takes no padding. The table sits flush so its\n     rules run into the card's border and the header row meets the corners; add\n     px-5 py-4 here and the whole table floats inside an edge it never touches.\n\n     The footer is the totals row. It belongs in the card rather than the table\n     because it is a summary of the query, not another record. -->\n<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-5 py-3.5\">\n    <div class=\"min-w-0\">\n      <h2 class=\"text-[14px]/5 font-semibold\">Order lines</h2>\n      <p class=\"text-[12px]/4 text-zinc-600\">4 of 9 lines · PO-24-1187</p>\n    </div>\n    <button type=\"button\" class=\"inline-flex h-8 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"download\" class=\"size-4 text-zinc-600\"></i>Export\n    </button>\n  </div>\n\n  <table class=\"w-full table-fixed text-left text-[13px]/5\">\n    <thead class=\"border-b border-zinc-200 bg-zinc-100 text-[11px]/4 tracking-wider text-zinc-600 uppercase\">\n      <tr>\n        <th scope=\"col\" class=\"px-5 py-2 font-medium\">Item</th>\n        <th scope=\"col\" class=\"hidden w-24 px-5 py-2 text-right font-medium sm:table-cell\">Qty</th>\n        <th scope=\"col\" class=\"w-32 px-5 py-2 text-right font-medium\">Value</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"truncate px-5 py-2.5\">MS angle 50×50×6</td>\n        <td class=\"hidden px-5 py-2.5 text-right tabular-nums sm:table-cell\">1,200 kg</td>\n        <td class=\"px-5 py-2.5 text-right tabular-nums\">₹6,84,000</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"truncate px-5 py-2.5\">MS plate 8mm</td>\n        <td class=\"hidden px-5 py-2.5 text-right tabular-nums sm:table-cell\">860 kg</td>\n        <td class=\"px-5 py-2.5 text-right tabular-nums\">₹5,16,000</td>\n      </tr>\n      <tr class=\"border-b border-zinc-100\">\n        <td class=\"truncate px-5 py-2.5\">Channel 100×50</td>\n        <td class=\"hidden px-5 py-2.5 text-right tabular-nums sm:table-cell\">440 kg</td>\n        <td class=\"px-5 py-2.5 text-right tabular-nums\">₹3,08,000</td>\n      </tr>\n      <tr>\n        <td class=\"truncate px-5 py-2.5\">Flat 40×6</td>\n        <td class=\"hidden px-5 py-2.5 text-right tabular-nums sm:table-cell\">390 kg</td>\n        <td class=\"px-5 py-2.5 text-right tabular-nums\">₹1,95,000</td>\n      </tr>\n    </tbody>\n  </table>\n\n  <div class=\"flex items-center justify-between border-t border-zinc-200 bg-zinc-100 px-5 py-3\">\n    <span class=\"text-[12px]/4 text-zinc-600\">Subtotal, 4 of 9 lines</span>\n    <span class=\"text-[14px]/5 font-semibold tabular-nums\">₹17,03,000</span>\n  </div>\n</div>"
        },
        {
          "id": "clickable",
          "name": "The whole card is a link",
          "html": "<!-- One anchor around the whole panel, so the target is the size of the card\n     rather than the size of its title.\n\n     It therefore contains no other link or button. A control nested inside an\n     anchor cannot be reached by keyboard and is ambiguous by mouse; if the card\n     needs a second action, the card stops being the link and the title becomes\n     one instead.\n\n     The chevron is aria-hidden — the accessible name is already the whole\n     card's text, and \"chevron right\" adds nothing to it. -->\n<div class=\"grid max-w-2xl items-start gap-3 sm:grid-cols-2\">\n  <a href=\"#\" class=\"group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400\">\n    <div class=\"flex items-start justify-between gap-3\">\n      <div class=\"min-w-0\">\n        <h3 class=\"truncate text-[14px]/5 font-semibold tabular-nums\">PO-24-1187</h3>\n        <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd</p>\n      </div>\n      <i data-lucide=\"chevron-right\" class=\"size-4 shrink-0 text-zinc-500 transition group-hover:text-zinc-900\" aria-hidden=\"true\"></i>\n    </div>\n    <div class=\"mt-3 flex items-center justify-between gap-2\">\n      <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n      </span>\n      <span class=\"text-[13px]/5 font-medium tabular-nums\">₹4,82,000</span>\n    </div>\n  </a>\n\n  <a href=\"#\" class=\"group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400\">\n    <div class=\"flex items-start justify-between gap-3\">\n      <div class=\"min-w-0\">\n        <h3 class=\"truncate text-[14px]/5 font-semibold tabular-nums\">PO-24-1186</h3>\n        <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Konkan Fabricators</p>\n      </div>\n      <i data-lucide=\"chevron-right\" class=\"size-4 shrink-0 text-zinc-500 transition group-hover:text-zinc-900\" aria-hidden=\"true\"></i>\n    </div>\n    <div class=\"mt-3 flex items-center justify-between gap-2\">\n      <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n      </span>\n      <span class=\"text-[13px]/5 font-medium tabular-nums\">₹1,15,400</span>\n    </div>\n  </a>\n</div>"
        },
        {
          "id": "grid",
          "name": "A row of cards",
          "html": "<!-- items-start is the whole point. A grid stretches its children to the\n     tallest by default, which pads the short cards with dead space at the\n     bottom and makes them read as though data is missing.\n\n     Let them size to their content and the ragged bottom edge is honest. -->\n<div class=\"grid items-start gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Vendor</h3>\n    </div>\n    <div class=\"px-5 py-4 text-[13px]/5\">\n      <p class=\"font-medium\">Gujarat Polymers Ltd</p>\n      <p class=\"mt-0.5 text-zinc-600\">Vapi, Gujarat</p>\n      <p class=\"mt-2 text-zinc-600\">GSTIN <span class=\"tabular-nums\">24AABCG1234M1Z5</span></p>\n    </div>\n  </div>\n\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Delivery</h3>\n    </div>\n    <div class=\"px-5 py-4 text-[13px]/5\">\n      <p>FOR Silvassa plant</p>\n    </div>\n  </div>\n\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Approvals</h3>\n    </div>\n    <div class=\"divide-y divide-zinc-100 text-[13px]/5\">\n      <div class=\"flex items-center gap-3 px-5 py-2.5\">\n        <span class=\"inline-flex size-7 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\" aria-hidden=\"true\">NP</span>\n        <span class=\"min-w-0 flex-1 truncate\">Nilesh Patil</span>\n        <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">14 Aug</span>\n      </div>\n      <div class=\"flex items-center gap-3 px-5 py-2.5\">\n        <span class=\"inline-flex size-7 items-center justify-center rounded-full bg-zinc-200 text-[11px]/4 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\" aria-hidden=\"true\">RD</span>\n        <span class=\"min-w-0 flex-1 truncate\">Ritu Deshpande</span>\n        <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">15 Aug</span>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "empty",
          "name": "With nothing in it",
          "html": "<!-- A card whose body is empty still keeps its header, because the header is\n     what the rest of the page refers to. What changes is the body: it says what\n     would be here and offers the one action that would put something in it.\n\n     No border and no fill on the inner block — a dashed box inside a bordered\n     card is a card inside a card. -->\n<div class=\"max-w-md rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n    <h2 class=\"text-[14px]/5 font-semibold\">Attached documents</h2>\n  </div>\n  <div class=\"px-5 py-10 text-center\">\n    <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n      <i data-lucide=\"paperclip\" class=\"size-5 text-zinc-600\"></i>\n    </span>\n    <p class=\"mt-3 text-[14px]/5 font-medium\">No documents yet</p>\n    <p class=\"mx-auto mt-1 max-w-[34ch] text-[13px]/5 text-zinc-600\">\n      The quotation and drawing are usually attached before the order goes to the vendor.\n    </p>\n    <button type=\"button\" class=\"mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n      <i data-lucide=\"upload\" class=\"size-4\"></i>Attach a document\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django loop",
          "html": "<!-- One card per record, and the empty case handled by {% empty %} rather than\n     by a separate {% if %} further up the template — the two get out of step\n     otherwise, and the page renders an empty grid with a heading over it.\n\n     get_absolute_url on the model, not a hard-coded path, so the card keeps\n     working when the URL conf moves. Nothing else in the card is a link. -->\n<div class=\"grid items-start gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n  {% for order in orders %}\n    <a href=\"{{ order.get_absolute_url }}\"\n       class=\"group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <h3 class=\"truncate text-[14px]/5 font-semibold tabular-nums\">{{ order.number }}</h3>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">{{ order.vendor.name }}</p>\n        </div>\n        <i data-lucide=\"chevron-right\" class=\"size-4 shrink-0 text-zinc-500 transition group-hover:text-zinc-900\" aria-hidden=\"true\"></i>\n      </div>\n      <div class=\"mt-3 flex items-center justify-between gap-2\">\n        <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full {{ order.status|status_dot }}\" aria-hidden=\"true\"></span>{{ order.get_status_display }}\n        </span>\n        <span class=\"text-[13px]/5 font-medium tabular-nums\">₹{{ order.value|intcomma }}</span>\n      </div>\n    </a>\n  {% empty %}\n    <div class=\"rounded-xl border border-zinc-200 bg-white px-5 py-10 text-center sm:col-span-2 lg:col-span-3\">\n      <p class=\"text-[14px]/5 font-medium\">No orders match this filter</p>\n      <p class=\"mx-auto mt-1 max-w-[38ch] text-[13px]/5 text-zinc-600\">Clear the filters, or widen the date range.</p>\n    </div>\n  {% endfor %}\n</div>"
        }
      ]
    },
    {
      "id": "separator",
      "name": "Separator",
      "category": "layout",
      "description": "The rule that divides content. Almost always a border on the thing beside it rather than an element of its own, and almost always silent to a screen reader.",
      "when_to_use": "Between two blocks of the same weight and the same width where nothing else says one has ended — the rows of a list, a footer that totals what is above it, the destructive item at the foot of a menu. Not between a heading and its body, and not between sections that already sit in separate cards: those are already separated, and a line there is decoration pretending to be structure.",
      "rules": [
        "Most of the time the right separator is no separator. Whitespace groups content perfectly well and costs nothing to read — a card title and its body are told apart by mt-1 and a weight change, with no rule between them. A line earns its place only when two blocks are the same size, the same width and adjacent, which is why the ones in this library are nearly all inside a card. Rule off every paragraph and the page reads as a form from 1998; by the fourth rule none of them means anything.",
        "The common divider is decorative and carries no role. There is nothing to announce and nothing to hide: a border is not an element at all, and a bare div is not a separator. Measured in Chrome's accessibility tree, divide-y, border-t and <div class=\"h-px bg-zinc-200\"> all resolve to a generic node with no name, which is exactly right. aria-hidden on a border is noise for the same reason. The semantic cases are two, and both are narrow: a menu, where role=\"separator\" is what tells a screen reader the destructive item is in a group of its own, and running prose where the subject genuinely changes, which is <hr>.",
        "<hr> is already the whole component under this stack, and is still the wrong tag for a list. Preflight zeroes its margins and gives it height 0 with border-top-width 1px, so <hr class=\"border-zinc-200\"> measures exactly 1px tall and full width with no margin, and resolves to role=\"separator\" with orientation horizontal — there is no h-px, no border-t and no role left to add. What it is wrong for is rows: forty <hr>s down a table of order lines announce forty separators nobody asked for, which is what the card entry means when it says dividers are borders.",
        "divide-y on the parent is the default, and it has exactly one failure. Tailwind v4 compiles divide-y to a border-bottom on & > :not(:last-child) — a DOM position, not a visibility. Hiding a middle row or the first row is therefore correct: measured, a four-row card went from 181px to 136px with the middle row x-shown away and the two remaining rules landed between the visible rows. Hiding the LAST row is not: the row above it is still not :last-child, keeps its border, and the card ends on a rule with nothing under it — measured 137px against 136px, and the extra pixel is the stray line. x-if is no better, because Alpine leaves the <template> in the DOM and the template takes the :last-child slot; measured, two visible rows still drew two rules. So put anything conditional outside the divided group with a border of its own, or render it server-side where {% if %} really does remove the node.",
        "A border on each child fails at the other end, and a border on a group member is worse. Per-child border-t survives a hidden last row and breaks on a hidden first one — measured 92px against 91px, the extra pixel being a rule across the top of the panel with nothing above it. It also does not survive being pasted anywhere else: the rule belongs to the row, so the row takes a line with it into whatever container it lands in, which divide-y never does. The sharpest version of this is the dropdown menu, which currently hangs its divider off the destructive button: measured with the two items above it conditionally hidden, the border still paints 5px below the panel's top edge — a rule introducing nothing. A separator that belongs to a group is its own element, not a border on one of the members.",
        "A standalone element between rows is the worst of the three. It needs the same condition as the row it introduces and nobody remembers to give it one: measured, hiding the row between two standalone rules left both in the document one pixel apart, at y 558 and y 559, which paints as a 2px band. Standalone is for a menu or a toolbar, where the separator is semantic and belongs to the group rather than to a row.",
        "Two shades and the rule is the length of the line. A horizontal rule inside a card that already has a zinc-200 border is divide-zinc-100 or border-zinc-100 — the registry does this in 15 of 15 divide-y uses. A full-width strip that changes what the region is — a card header, a footer that totals, a sticky action bar — is border-zinc-200, because it is an edge and not a divider. Every vertical rule is zinc-200 regardless: a horizontal rule is 300px of ink and zinc-100 carries it, a vertical one is 16px and zinc-100 vanishes. Never a zinc-100 rule on the zinc-100 page ground — measured, the line and the surface compute to the identical colour, so it is not low contrast, it is absent. On white at devicePixelRatio 1 a zinc-100 rule paints rgb(244,244,245) against rgb(255,255,255) and a zinc-200 rule paints rgb(228,228,231); that first one is already at the floor, which is why it only ever sits on white.",
        "A rule is not a tinted shape, so it takes no ring. The ring exists to hold the edge of a filled shape against the surface behind it, and a line one pixel tall is entirely edge. Measured: ring-1 ring-inset ring-zinc-300 on a 1px rule adds no height at all and simply repaints the whole line zinc-300, so the only thing it changed was the shade you already chose; without ring-inset it paints a 3px sandwich of zinc-300, zinc-200, zinc-300 where a 1px divider was wanted.",
        "A vertical rule has no height of its own. In flex items-center — every toolbar and meta line here — a bare w-px div measured 0px tall and painted nothing at all. h-5 is the answer beside controls: a fixed 20px whatever the neighbours do, so a row holding a 13px label and a 24px figure does not grow a rule as tall as the figure. self-stretch is the answer when the rule should run the whole row: measured 28px in a row whose tallest item was 28px, which means it tracks whichever neighbour got taller. Add shrink-0 either way, or flex takes the pixel back when the row is tight. For a fixed set of columns, divide-x on the grid is simpler than any of this and cannot be left behind by a hidden child.",
        "A labelled rule is a heading with decoration, not a separator with a label. Put the text in the h2 or h3 the page outline already wants and make the two lines aria-hidden spans with flex-1 — measured, the label then appears in the accessibility tree as a level-3 heading and the lines appear nowhere, which is what somebody skimming by heading needs. A role=\"separator\" carrying aria-label reads the same words out but adds nothing to the outline. The exception is a label that names a choice rather than a section — the \"or\" between two ways of signing in — which is plain text between two hidden lines and no role at all.",
        "A 1px rule is 1 CSS pixel at every density, and only fractional densities soften it. Measured off the rendered pixels: at devicePixelRatio 1 every form — h-px, border-t and <hr> alike — paints one device row at full strength; at 2 it paints two device rows at full strength; at 1.5 it paints one full row plus one half-strength row, and which side gets the soft row depends on where the rule landed in the layout, so two rules in the same card can blur on opposite edges. Nothing disappears and nothing needs a fix. It is one more reason zinc-100 only ever sits on white: at 1.5 its soft row measured rgb(249,249,250), six values off the background."
      ],
      "anatomy": [
        {
          "part": "Rule",
          "description": "One CSS pixel of colour and nothing else — border-t border-zinc-100 on the block below it, or h-px bg-zinc-100 as an element where the line has to stand alone."
        },
        {
          "part": "Divided group",
          "description": "divide-y divide-zinc-100 on the parent of a set of rows. The rule belongs to the list, so a row carries no line out with it."
        },
        {
          "part": "Strip",
          "description": "border-t border-zinc-200 on a block that ends a region — a totals bar, a footer, a conditional summary. Darker than a divider because it is an edge."
        },
        {
          "part": "Vertical rule",
          "description": "h-5 w-px shrink-0 bg-zinc-200 between clusters in a toolbar, or divide-x divide-zinc-200 across a fixed grid of columns."
        },
        {
          "part": "Label",
          "description": "The middle of a labelled rule: a real heading, with flex-1 aria-hidden spans either side carrying the line."
        },
        {
          "part": "Menu separator",
          "description": "The one that is semantic. role=\"separator\" as its own element inside role=\"menu\", my-1 h-px bg-zinc-100, sitting above the destructive item."
        }
      ],
      "behaviour": [
        "A rule is 1px at every zoom and every density. It never scales with the text around it and never carries a second weight for emphasis.",
        "A horizontal rule inside a card runs the full width of the card, which means the padding belongs to the rows and not to the group holding them. Put the padding on the parent and every rule is inset by it, which reads as a crack in the card rather than a divider across it.",
        "A divided group's rules are on the parent, so hiding a row moves the remaining lines and does not leave one behind — except at the last row, which is why a conditional block sits outside the group with its own border.",
        "A vertical rule takes its height from a utility, never from its neighbours. Given none, it renders at zero and disappears.",
        "Nothing is focusable, nothing responds to hover, and nothing animates. A separator that can be dragged is a splitter, which is a different control and not one this library has.",
        "At 390px every form here holds without scrolling sideways: the toolbar rule stays 20px, the three-column divide-x grid stays three columns, and a labelled rule keeps a visible line on both sides of the label."
      ],
      "accessibility": [
        "The default divider has no role at all. It is a border, or a div with a background and no name, and it resolves to a generic node — silent, which is correct for a line that only groups things visually.",
        "Never put aria-hidden on a border. There is no element to hide, and on a bare div it is redundant with having no name.",
        "role=\"separator\" is for a menu or a toolbar, where a screen reader has no other way to learn that the items above and below it are different groups. Verified in the tree: the menu exposes menuitem, menuitem, separator, menuitem.",
        "A vertical separator that is genuinely semantic adds aria-orientation=\"vertical\", which resolves as orientation vertical. A decorative one adds nothing.",
        "The label on a labelled rule is a heading, so it lands in the page outline; the lines beside it are aria-hidden and land nowhere.",
        "Do not put a separator inside a list. role=\"list\" permits listitem children, and an <li role=\"separator\"> is exposed by Chrome as a separator sitting among the items — divide the list with divide-y and leave the list a list.",
        "<hr> already carries role=\"separator\" with a horizontal orientation, which is why it belongs where the subject changes and not between forty rows."
      ],
      "related": [
        "card",
        "dropdown",
        "button-group"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Horizontal rule",
          "html": "<!-- The common divider is a border on the block below it, and it carries no\n     role. A line between two paragraphs of the same section groups them for the\n     eye; announced as a separator it is one more thing to step over. Measured,\n     this rule resolves to a generic node with no name, which is right, and\n     needs no aria-hidden because there is no element to hide.\n\n     zinc-100 because the card around it is already zinc-200. On the page ground\n     the two would be the identical colour and the line would not be faint, it\n     would be missing.\n\n     The <hr> is the other case and the rarer one: the subject changes. Under\n     preflight it is already a 1px full-width rule with no margins and already\n     role=\"separator\" — border-zinc-200 sets its colour and there is nothing\n     else to add. Use it where you would start a new heading, never between the\n     rows of a list. -->\n<div class=\"max-w-md rounded-xl border border-zinc-200 bg-white p-5\">\n  <h2 class=\"text-[16px]/6 font-semibold\">Payment terms</h2>\n  <p class=\"mt-1 text-[13px]/5 text-zinc-600\">45 days from GRN, against a clean receipt note.</p>\n\n  <div class=\"mt-4 border-t border-zinc-100 pt-4\">\n    <p class=\"text-[13px]/5 text-zinc-600\">Retention of 5% is released after the trial run is signed off.</p>\n  </div>\n\n  <hr class=\"my-5 border-zinc-200\">\n\n  <h3 class=\"text-[13px]/5 font-medium\">Amendment history</h3>\n  <p class=\"mt-1 text-[12px]/4 text-zinc-500\">Two revisions since release, both to the delivery date.</p>\n</div>"
        },
        {
          "id": "rows",
          "name": "Rows in a card",
          "html": "<!-- divide-y on the parent, not a border on each row. The rule then belongs to\n     the list rather than to the row, so a row pasted into another container\n     does not take a stray line with it.\n\n     Tailwind v4 compiles divide-y to a border-bottom on & > :not(:last-child),\n     which is a DOM position and not a visibility. Hiding a middle row is\n     therefore fine — measured, the container went 181px to 136px and the\n     remaining rules landed between the visible rows. Hiding the last row is\n     not: the row above it is still not :last-child, so it keeps its border and\n     the card ends on a rule with nothing under it. Measured 137px against\n     136px, and the extra pixel is that line. An x-if does not help either,\n     because Alpine leaves the <template> in the DOM and the template takes the\n     :last-child slot.\n\n     So the conditional block sits outside the divided group and brings its own\n     border. It is border-zinc-200 rather than zinc-100 because it is a strip\n     that ends the card, the same weight as the header above it, not another\n     row. x-cloak because it is hidden at first paint. -->\n<div class=\"max-w-md overflow-hidden rounded-xl border border-zinc-200 bg-white\" x-data=\"{ cancelled: false }\">\n  <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 px-5 py-3.5\">\n    <h2 class=\"text-[14px]/5 font-semibold\">Order lines</h2>\n    <button type=\"button\" @click=\"cancelled = !cancelled\"\n            class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\"\n            x-text=\"cancelled ? 'Hide cancelled' : 'Show cancelled'\">Show cancelled</button>\n  </div>\n\n  <dl class=\"divide-y divide-zinc-100 text-[13px]/5\">\n    <div class=\"flex items-center justify-between gap-4 px-5 py-2.5\">\n      <dt class=\"min-w-0 truncate text-zinc-600\">MS angle 50×50×6</dt>\n      <dd class=\"shrink-0 tabular-nums\">₹6,84,000</dd>\n    </div>\n    <div class=\"flex items-center justify-between gap-4 px-5 py-2.5\">\n      <dt class=\"min-w-0 truncate text-zinc-600\">MS plate 8mm</dt>\n      <dd class=\"shrink-0 tabular-nums\">₹5,16,000</dd>\n    </div>\n    <div class=\"flex items-center justify-between gap-4 px-5 py-2.5\">\n      <dt class=\"min-w-0 truncate text-zinc-600\">Channel 100×50</dt>\n      <dd class=\"shrink-0 tabular-nums\">₹3,08,000</dd>\n    </div>\n  </dl>\n\n  <div x-show=\"cancelled\" x-cloak\n       class=\"border-t border-zinc-200 bg-zinc-100 px-5 py-2.5 text-[12px]/4 text-zinc-600\">\n    2 cancelled lines worth <span class=\"tabular-nums\">₹1,04,000</span>, not counted above.\n  </div>\n</div>"
        },
        {
          "id": "vertical",
          "name": "Vertical",
          "html": "<!-- A vertical rule has no height of its own. In flex items-center a bare\n     w-px div measured 0px tall and painted nothing; h-5 gives it a fixed 20px\n     that does not grow when a neighbour does. shrink-0 keeps flex from taking\n     the pixel back when the row is tight.\n\n     zinc-200, not zinc-100. This line is 20px long against a horizontal rule's\n     300px, and zinc-100 at that length is invisible.\n\n     Neither rule carries a role. The clusters either side are already named by\n     role=\"group\" and an aria-label, which tells a screen reader far more than a\n     separator between them would; the line is what the eye uses to see the\n     grouping the label already states.\n\n     For a fixed set of columns, divide-x on the grid is the whole job — no\n     heights to manage and no element that can be left behind by a hidden\n     child. -->\n<div class=\"space-y-3\">\n  <div class=\"flex max-w-md flex-wrap items-center gap-2 rounded-xl border border-zinc-200 bg-white p-2\">\n    <div role=\"group\" aria-label=\"Row height\" class=\"flex items-center gap-1\">\n      <button type=\"button\" aria-label=\"Compact rows\" aria-pressed=\"true\"\n              class=\"flex size-8 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n        <i data-lucide=\"align-justify\" class=\"size-4 text-zinc-700\"></i>\n      </button>\n      <button type=\"button\" aria-label=\"Comfortable rows\" aria-pressed=\"false\"\n              class=\"flex size-8 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n        <i data-lucide=\"menu\" class=\"size-4\"></i>\n      </button>\n    </div>\n\n    <div class=\"h-5 w-px shrink-0 bg-zinc-200\"></div>\n\n    <div role=\"group\" aria-label=\"Export\" class=\"flex items-center gap-1\">\n      <button type=\"button\" aria-label=\"Download CSV\"\n              class=\"flex size-8 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n        <i data-lucide=\"download\" class=\"size-4\"></i>\n      </button>\n      <button type=\"button\" aria-label=\"Print\"\n              class=\"flex size-8 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900\">\n        <i data-lucide=\"printer\" class=\"size-4\"></i>\n      </button>\n    </div>\n  </div>\n\n  <div class=\"grid max-w-md grid-cols-3 divide-x divide-zinc-200 rounded-xl border border-zinc-200 bg-white text-center\">\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Received</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹11,42,040</p>\n    </div>\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Invoiced</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹9,80,000</p>\n    </div>\n    <div class=\"px-3 py-3\">\n      <p class=\"text-[11px]/4 tracking-wider text-zinc-500 uppercase\">Balance</p>\n      <p class=\"mt-1 text-[16px]/6 font-semibold tabular-nums\">₹6,99,960</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "labelled",
          "name": "With a label",
          "html": "<!-- The label is a heading, not a separator with a name. It says what follows,\n     which is what a heading is for, and it then appears in the page outline for\n     anyone skimming by heading — measured, this one resolves as a level-3\n     heading while the two lines beside it resolve to nothing at all. Wrapping\n     the row in role=\"separator\" with an aria-label reads the same words out and\n     leaves the outline empty.\n\n     flex-1 on the lines and shrink-0 on the label: the label keeps its width\n     and the lines take whatever is left, so at 390px there is still a visible\n     rule on both sides rather than one line and a stub.\n\n     The second one is the exception — a label that names a choice rather than a\n     section. \"or\" is plain text with no role, because that is exactly what it\n     is; only the lines are hidden. -->\n<div class=\"max-w-md rounded-xl border border-zinc-200 bg-white p-5\">\n  <p class=\"text-[13px]/5 text-zinc-600\">Nine lines released to the vendor on 04 Aug 2026.</p>\n\n  <div class=\"mt-5 flex items-center gap-3\">\n    <span class=\"h-px flex-1 bg-zinc-200\" aria-hidden=\"true\"></span>\n    <h3 class=\"shrink-0 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\">Amendments</h3>\n    <span class=\"h-px flex-1 bg-zinc-200\" aria-hidden=\"true\"></span>\n  </div>\n\n  <p class=\"mt-3 text-[13px]/5 text-zinc-600\">Rev C moved the delivery date to 28 Aug 2026.</p>\n\n  <div class=\"mt-5 flex items-center gap-3\">\n    <span class=\"h-px flex-1 bg-zinc-200\" aria-hidden=\"true\"></span>\n    <span class=\"shrink-0 text-[12px]/4 text-zinc-500\">or</span>\n    <span class=\"h-px flex-1 bg-zinc-200\" aria-hidden=\"true\"></span>\n  </div>\n</div>"
        },
        {
          "id": "menu",
          "name": "Inside a menu",
          "html": "<!-- The one place the separator is real. A menu is a list of peers, and the\n     only thing saying Cancel order is not another Print is the line above it —\n     so that line has to be in the accessibility tree too. Measured, the panel\n     exposes menuitem, menuitem, separator, menuitem.\n\n     It is its own element, not a border-t on the destructive button. Hang it\n     off the button and it belongs to the button: measured with the items above\n     it conditionally hidden, the border still painted 5px below the panel's top\n     edge, introducing nothing. As its own element it can be dropped in the same\n     {% if %} as the item it introduces.\n\n     zinc-100 and full bleed, my-1 for the breathing room the items already have\n     through their padding. The panel is the zinc-200 edge; the divider inside\n     it is a step lighter, the same as in any card. -->\n<div class=\"relative max-w-xs\" x-data=\"{ open: false }\" @click.outside=\"open = false\">\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-haspopup=\"menu\"\n          class=\"flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n    <i data-lucide=\"ellipsis\" class=\"size-4 text-zinc-600\"></i>Actions\n  </button>\n\n  <div x-show=\"open\" x-cloak role=\"menu\" aria-label=\"Order actions\"\n       class=\"absolute left-0 z-40 mt-1 w-52 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"pencil\" class=\"size-4 text-zinc-600\"></i>Amend\n    </button>\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"printer\" class=\"size-4 text-zinc-600\"></i>Print\n    </button>\n\n    <div role=\"separator\" class=\"my-1 h-px bg-zinc-100\"></div>\n\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 text-red-600 hover:bg-zinc-100\">\n      <i data-lucide=\"trash-2\" class=\"size-4\"></i>Cancel order\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django partial",
          "html": "<!-- Two shapes, one file. What is worth centralising is not the div — it is\n     the shade rule, that a horizontal rule inside a bordered panel is zinc-100\n     and a vertical one is zinc-200. The fortieth template to need a divider is\n     the one that gets it wrong.\n\n     The tag is for the standalone cases only: a menu, a toolbar. A divided list\n     needs no tag at all, because there is nothing to repeat — it is one class\n     on the wrapper.\n\n     And the server is the only place divide-y is safe on a list that can\n     change. {% if %} removes the node, so :last-child lands on the last row\n     that actually rendered. Alpine cannot do this: x-show leaves the row in the\n     DOM and x-if leaves the <template> in the DOM, and either way the rule\n     above the hidden row is stranded at the bottom of the card.\n\n     # templatetags/ui.py\n     @register.inclusion_tag('ui/_separator.html')\n     def separator(orientation='horizontal'):\n         return {'vertical': orientation == 'vertical'}\n\n     # templates/ui/_separator.html\n     {% if vertical %}\n       <div role=\"separator\" aria-orientation=\"vertical\" class=\"h-5 w-px shrink-0 bg-zinc-200\"></div>\n     {% else %}\n       <div role=\"separator\" class=\"my-1 h-px bg-zinc-100\"></div>\n     {% endif %} -->\n{% load humanize ui %}\n\n<div role=\"menu\" aria-label=\"Order actions\"\n     class=\"w-52 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n  {% for action in order.menu_actions %}\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n      <i data-lucide=\"{{ action.icon }}\" class=\"size-4 text-zinc-600\"></i>{{ action.label }}\n    </button>\n  {% endfor %}\n\n  {# the separator sits in the same if-block as the item it introduces #}\n  {% if order.can_cancel %}\n    {% separator %}\n    <button type=\"button\" role=\"menuitem\" class=\"flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px]/5 text-red-600 hover:bg-zinc-100\">\n      <i data-lucide=\"trash-2\" class=\"size-4\"></i>Cancel order\n    </button>\n  {% endif %}\n</div>\n\n<dl class=\"mt-4 divide-y divide-zinc-100 overflow-hidden rounded-xl border border-zinc-200 bg-white text-[13px]/5\">\n  {% for line in order.active_lines %}\n    <div class=\"flex items-center justify-between gap-4 px-5 py-2.5\">\n      <dt class=\"min-w-0 truncate text-zinc-600\">{{ line.item }}</dt>\n      <dd class=\"shrink-0 tabular-nums\">₹{{ line.value|intcomma }}</dd>\n    </div>\n  {% empty %}\n    <p class=\"py-4 text-center text-[13px]/5 text-zinc-500\">No open lines on this order.</p>\n  {% endfor %}\n</dl>"
        }
      ]
    },
    {
      "id": "carousel",
      "name": "Carousel",
      "category": "layout",
      "description": "A horizontal rail of items that snaps one item at a time. CSS scroll snap does the moving; the arrows and the counter are the only JavaScript.",
      "when_to_use": "A set of items that will not fit and does not need comparing: photographs against an inspection, drawings on a job, a strip of cards on a phone where the desktop layout is a grid. Never for anything that has to be read in full or compared across rows — that is a table or a grid, and both show everything at once.",
      "rules": [
        "No autoplay, ever. Nothing moves unless a person moved it. A rail that advances on a timer pulls the row out from under the cursor mid-read, and there is no rotation speed that is right for two different readers.",
        "The engine is CSS scroll snap, never a translated track. snap-x snap-mandatory overflow-x-auto on the rail, snap-start shrink-0 on each item. Touch, trackpad, keyboard and find-in-page all work before a line of JavaScript is written; a transform track has to reimplement every one of them and gets most of them wrong.",
        "This is the one component allowed to scroll sideways on a phone. The rule holds everywhere else. It is allowed here because the scroll is the component, it snaps, and the next item peeks so nobody has to guess there is more.",
        "Every item stays in the DOM and none of them is aria-hidden. Offscreen is not hidden — Ctrl+F must find the sixth card and the tab order must reach the link inside it.",
        "It is a scrolling region, not an ARIA carousel. The rail takes role=\"group\", an accessible name and tabindex=\"0\", because a scrollable region that cannot be focused cannot be scrolled by keyboard at all. aria-roledescription=\"carousel\" describes the rotating banner this deliberately is not.",
        "A counter reads \"item i of n\" and is only honest when one item fills the rail. With two or three visible it is a lie — drop it and let the arrows and the peek carry it. Dots have the same constraint, and stop being aimable past about six.",
        "The counter is not a live region. It would fire on every frame of a swipe. Nothing needs announcing: every slide is already in the DOM and reachable, so nothing is being hidden from a screen reader.",
        "Arrows are a pointer affordance, hidden below md where the gesture is a swipe and a 32px target beside a thumb is a miss. They disable at the ends instead of wrapping — a rail that jumps back to the first item hides the fact that the last one was the last one.",
        "Scroll by a measured delta, never by clientWidth. The target item's getBoundingClientRect minus the rail's, less the rail's scroll-padding, lands exactly on the snap point at every breakpoint. Paging by container width drifts the moment an item is a fraction of it, and drops the last partial page.",
        "Filter <template> out before counting items. Alpine leaves the x-for template in the DOM as an element child, so children.length comes back one too many and a six-photo gallery counts to seven.",
        "Smooth scrolling has to check prefers-reduced-motion itself. behavior: \"smooth\" ignores the media query, so read it and pass \"auto\" instead.",
        "The scrollbar is hidden on the rail: the peek, the arrows and the counter already say it scrolls, and a classic scrollbar adds 15px of grey under every row on Windows. It takes both [scrollbar-width:none] and [&::-webkit-scrollbar]:hidden — Firefox honours the first, Chrome and Safari the second."
      ],
      "anatomy": [
        {
          "part": "Rail",
          "description": "The scroll container: flex, snap-x snap-mandatory, overflow-x-auto, tabindex=\"0\" and a name. Everything else is optional."
        },
        {
          "part": "Item",
          "description": "snap-start shrink-0 with a declared width — w-72 for a strip of records, basis-[86%] sm:basis-1/2 for a gallery that peeks."
        },
        {
          "part": "Peek",
          "description": "The sliver of the next item left visible by sizing items under 100%. It is the affordance; without it a rail reads as a static panel."
        },
        {
          "part": "Arrows",
          "description": "A disabled-at-the-ends pair, hidden below md. Either in the header beside the title, or overlaid on an image."
        },
        {
          "part": "Indicator",
          "description": "A counter or a row of dots, and only when one item fills the rail. Both are optional; neither is a live region."
        },
        {
          "part": "Header",
          "description": "The title and the controls on one line above the rail, or the card header when the rail lives inside a card."
        }
      ],
      "behaviour": [
        "Nothing moves on its own. There is no autoplay, no pause control, and no timer to get wrong.",
        "Dragging, swiping, a trackpad and shift+wheel all move the rail natively, and it settles on a snap point.",
        "The arrows step one item and stop at the ends. They never wrap.",
        "Arrow keys step the rail when the rail itself has focus, and are left alone when focus is inside an item, so a field inside a card still takes its own keystrokes.",
        "The active index is read back from scroll position, not tracked separately, so a swipe, an arrow click and a tab into an offscreen item all agree.",
        "Below md the arrows are gone and the gesture is the whole interface, which is why the peek and the snap are not optional."
      ],
      "accessibility": [
        "The rail is focusable with tabindex=\"0\" and carries role=\"group\" plus an accessible name — a scrollable region with no tab stop is unreachable by keyboard.",
        "No aria-roledescription=\"carousel\" and no slide roles. This is a scrolling list of real content, and describing it as a carousel promises rotation semantics that do not exist here.",
        "Offscreen items are never aria-hidden and never removed. They stay findable, tabbable and readable in DOM order.",
        "Arrow buttons carry aria-label (\"Previous purchase order\", not \"Previous\") and are genuinely disabled at the ends, not just faded.",
        "The counter is plain text. Making it a live region turns one swipe into a stream of announcements for no information a reader cannot already get.",
        "Dots are buttons with a label naming the destination, and the current one carries aria-current, so the set is usable without seeing which is filled."
      ],
      "related": [
        "card",
        "attachment",
        "pagination"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\">\n\n  <div class=\"mb-3 flex items-center justify-between gap-3\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Orders awaiting approval</h3>\n    <div class=\"hidden items-center gap-1 md:flex\">\n      <button type=\"button\" @click=\"go(i - 1)\" :disabled=\"atStart\" aria-label=\"Previous orders\"\n              class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n        <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n      </button>\n      <button type=\"button\" @click=\"go(i + 1)\" :disabled=\"atEnd\" aria-label=\"More orders\"\n              class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n        <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n      </button>\n    </div>\n  </div>\n\n  <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Orders awaiting approval\"\n       @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n       @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n       class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n\n    <article class=\"w-72 shrink-0 snap-start rounded-xl border border-zinc-200 bg-white p-4\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-[14px]/5 font-semibold\">PO-24-1187</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd</p>\n        </div>\n        <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Approved\n        </span>\n      </div>\n      <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n          <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹18,42,000</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n          <dd class=\"text-[12px]/4 tabular-nums\">28 Aug 2026</dd>\n        </div>\n      </dl>\n    </article>\n\n    <article class=\"w-72 shrink-0 snap-start rounded-xl border border-zinc-200 bg-white p-4\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-[14px]/5 font-semibold\">PO-24-1191</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Nashik Steel Traders</p>\n        </div>\n        <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>Overdue\n        </span>\n      </div>\n      <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n          <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹4,68,500</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n          <dd class=\"text-[12px]/4 tabular-nums text-red-600\">11 Aug 2026</dd>\n        </div>\n      </dl>\n    </article>\n\n    <article class=\"w-72 shrink-0 snap-start rounded-xl border border-zinc-200 bg-white p-4\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-[14px]/5 font-semibold\">PO-24-1194</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Sharma Extrusions</p>\n        </div>\n        <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full bg-zinc-500\" aria-hidden=\"true\"></span>Open\n        </span>\n      </div>\n      <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n          <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹96,750</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n          <dd class=\"text-[12px]/4 tabular-nums\">04 Sep 2026</dd>\n        </div>\n      </dl>\n    </article>\n\n    <article class=\"w-72 shrink-0 snap-start rounded-xl border border-zinc-200 bg-white p-4\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-[14px]/5 font-semibold\">PO-24-1198</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Deshpande Traders</p>\n        </div>\n        <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full bg-emerald-600\" aria-hidden=\"true\"></span>Closed\n        </span>\n      </div>\n      <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n          <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹2,31,900</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n          <dd class=\"text-[12px]/4 tabular-nums\">02 Aug 2026</dd>\n        </div>\n      </dl>\n    </article>\n\n    <article class=\"w-72 shrink-0 snap-start rounded-xl border border-zinc-200 bg-white p-4\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <p class=\"truncate text-[14px]/5 font-semibold\">PO-24-1203</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd</p>\n        </div>\n        <span class=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-zinc-200 px-2.5 py-0.5 text-[12px]/4 font-medium text-zinc-600 ring-1 ring-inset ring-zinc-300\">\n          <span class=\"size-1.5 rounded-full bg-zinc-400\" aria-hidden=\"true\"></span>Draft\n        </span>\n      </div>\n      <dl class=\"mt-3 space-y-1.5 border-t border-zinc-100 pt-3\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Value</dt>\n          <dd class=\"text-[12px]/4 font-medium tabular-nums\">₹7,04,200</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-[12px]/4 text-zinc-600\">Delivery</dt>\n          <dd class=\"text-[12px]/4 text-zinc-500\">Not set</dd>\n        </div>\n      </dl>\n    </article>\n  </div>\n</div>"
        },
        {
          "id": "peek",
          "name": "Peek",
          "html": "<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\">\n\n  <div class=\"mb-3 flex items-baseline justify-between gap-3\">\n    <div class=\"min-w-0\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Other orders on this supplier</h3>\n      <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd, last six months</p>\n    </div>\n    <div class=\"hidden shrink-0 items-center gap-1 md:flex\">\n      <button type=\"button\" @click=\"go(i - 1)\" :disabled=\"atStart\" aria-label=\"Previous orders\"\n              class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n        <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n      </button>\n      <button type=\"button\" @click=\"go(i + 1)\" :disabled=\"atEnd\" aria-label=\"More orders\"\n              class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n        <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n      </button>\n    </div>\n  </div>\n\n  <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Other orders on Gujarat Polymers Ltd\"\n       @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n       @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n       class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n\n    <a href=\"#\" class=\"group shrink-0 basis-[86%] snap-start rounded-xl border border-zinc-200 bg-white p-4 hover:border-zinc-300 hover:bg-zinc-50 sm:basis-1/2 xl:basis-1/3\">\n      <p class=\"text-[14px]/5 font-semibold group-hover:underline group-hover:underline-offset-2\">PO-24-1163</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">HDPE granules, 12 MT · Silvassa plant</p>\n      <p class=\"mt-3 text-[16px]/6 font-semibold tabular-nums\">₹9,12,400</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500 tabular-nums\">Received 22 Mar 2026</p>\n    </a>\n\n    <a href=\"#\" class=\"group shrink-0 basis-[86%] snap-start rounded-xl border border-zinc-200 bg-white p-4 hover:border-zinc-300 hover:bg-zinc-50 sm:basis-1/2 xl:basis-1/3\">\n      <p class=\"text-[14px]/5 font-semibold group-hover:underline group-hover:underline-offset-2\">PO-24-1179</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">Masterbatch, black · Silvassa plant</p>\n      <p class=\"mt-3 text-[16px]/6 font-semibold tabular-nums\">₹1,86,000</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500 tabular-nums\">Received 08 May 2026</p>\n    </a>\n\n    <a href=\"#\" class=\"group shrink-0 basis-[86%] snap-start rounded-xl border border-zinc-200 bg-white p-4 hover:border-zinc-300 hover:bg-zinc-50 sm:basis-1/2 xl:basis-1/3\">\n      <p class=\"text-[14px]/5 font-semibold group-hover:underline group-hover:underline-offset-2\">PO-24-1185</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">HDPE granules, 8 MT · Silvassa plant</p>\n      <p class=\"mt-3 text-[16px]/6 font-semibold tabular-nums\">₹6,08,300</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500 tabular-nums\">Received 19 Jun 2026</p>\n    </a>\n\n    <a href=\"#\" class=\"group shrink-0 basis-[86%] snap-start rounded-xl border border-zinc-200 bg-white p-4 hover:border-zinc-300 hover:bg-zinc-50 sm:basis-1/2 xl:basis-1/3\">\n      <p class=\"text-[14px]/5 font-semibold group-hover:underline group-hover:underline-offset-2\">PO-24-1186</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">Antioxidant additive · Silvassa plant</p>\n      <p class=\"mt-3 text-[16px]/6 font-semibold tabular-nums\">₹74,150</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500 tabular-nums\">Received 30 Jun 2026</p>\n    </a>\n\n    <a href=\"#\" class=\"group shrink-0 basis-[86%] snap-start rounded-xl border border-zinc-200 bg-white p-4 hover:border-zinc-300 hover:bg-zinc-50 sm:basis-1/2 xl:basis-1/3\">\n      <p class=\"text-[14px]/5 font-semibold group-hover:underline group-hover:underline-offset-2\">PO-24-1187</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-600\">HDPE granules, 15 MT · Silvassa plant</p>\n      <p class=\"mt-3 text-[16px]/6 font-semibold tabular-nums\">₹18,42,000</p>\n      <p class=\"mt-1 text-[12px]/4 text-zinc-500 tabular-nums\">Received 04 Aug 2026</p>\n    </a>\n  </div>\n</div>"
        },
        {
          "id": "dots",
          "name": "Dots",
          "html": "<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\">\n\n  <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Plant notices\"\n       @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n       @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n       class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n\n    <article class=\"flex shrink-0 basis-full snap-start gap-3 rounded-xl border border-zinc-200 bg-white p-4\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n        <i data-lucide=\"wrench\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n      <div class=\"min-w-0\">\n        <p class=\"text-[14px]/5 font-semibold\">Extruder line 2 shutdown, 24 to 26 August</p>\n        <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Planned gearbox replacement. Compounding orders due that week move to line 4, so expect a two day slip on anything scheduled after the 23rd.</p>\n        <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Maintenance · Sanjay More · 12 Aug 2026</p>\n      </div>\n    </article>\n\n    <article class=\"flex shrink-0 basis-full snap-start gap-3 rounded-xl border border-zinc-200 bg-white p-4\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n        <i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n      <div class=\"min-w-0\">\n        <p class=\"text-[14px]/5 font-semibold\">Purchase orders above ₹5,00,000 need a second approval</p>\n        <p class=\"mt-1 text-[13px]/5 text-zinc-600\">From 1 September the second approver is the cost centre head, not Finance. Orders already raised keep the approval they were created with.</p>\n        <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Procurement · Nilesh Patil · 09 Aug 2026</p>\n      </div>\n    </article>\n\n    <article class=\"flex shrink-0 basis-full snap-start gap-3 rounded-xl border border-zinc-200 bg-white p-4\">\n      <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n        <i data-lucide=\"truck\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n      <div class=\"min-w-0\">\n        <p class=\"text-[14px]/5 font-semibold\">Gate timings for inward material</p>\n        <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Unloading now closes at 18:00 instead of 20:00. Vehicles reporting after 18:00 are held overnight and weighed the next morning.</p>\n        <p class=\"mt-2 text-[12px]/4 text-zinc-500\">Stores · Meera Joshi · 01 Aug 2026</p>\n      </div>\n    </article>\n  </div>\n\n  <div class=\"mt-3 flex items-center justify-center gap-1\">\n    <button type=\"button\" @click=\"go(0)\" :aria-current=\"i === 0 ? 'true' : 'false'\" aria-label=\"Notice 1, extruder line 2 shutdown\"\n            class=\"flex size-6 items-center justify-center rounded-md focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <span class=\"h-1.5 rounded-full transition-all\" :class=\"i === 0 ? 'w-4 bg-zinc-700' : 'w-1.5 bg-zinc-300'\"></span>\n    </button>\n    <button type=\"button\" @click=\"go(1)\" :aria-current=\"i === 1 ? 'true' : 'false'\" aria-label=\"Notice 2, second approval limit\"\n            class=\"flex size-6 items-center justify-center rounded-md focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <span class=\"h-1.5 rounded-full transition-all\" :class=\"i === 1 ? 'w-4 bg-zinc-700' : 'w-1.5 bg-zinc-300'\"></span>\n    </button>\n    <button type=\"button\" @click=\"go(2)\" :aria-current=\"i === 2 ? 'true' : 'false'\" aria-label=\"Notice 3, gate timings\"\n            class=\"flex size-6 items-center justify-center rounded-md focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <span class=\"h-1.5 rounded-full transition-all\" :class=\"i === 2 ? 'w-4 bg-zinc-700' : 'w-1.5 bg-zinc-300'\"></span>\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "photo",
          "name": "Photo gallery",
          "html": "<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\">\n\n  <div class=\"relative\">\n    <!-- the zinc-100 wells stand in for <img class=\"h-full w-full object-cover\">, as written out in the Django variant -->\n    <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Photographs on inspection QC-24-0412\"\n         @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n         @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n         class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n\n      <figure class=\"shrink-0 basis-full snap-start overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n        <div class=\"flex aspect-video items-center justify-center bg-zinc-100\">\n          <i data-lucide=\"image\" class=\"size-6 text-zinc-500\"></i>\n        </div>\n        <figcaption class=\"border-t border-zinc-200 px-4 py-2.5\">\n          <p class=\"truncate text-[13px]/5 font-medium\">Consignment on the weighbridge</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-500 tabular-nums\">gate-inward-01.jpg · 04 Aug 2026 09:12 IST</p>\n        </figcaption>\n      </figure>\n\n      <figure class=\"shrink-0 basis-full snap-start overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n        <div class=\"flex aspect-video items-center justify-center bg-zinc-100\">\n          <i data-lucide=\"image\" class=\"size-6 text-zinc-500\"></i>\n        </div>\n        <figcaption class=\"border-t border-zinc-200 px-4 py-2.5\">\n          <p class=\"truncate text-[13px]/5 font-medium\">Bag markings against the packing list</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-500 tabular-nums\">gate-inward-02.jpg · 04 Aug 2026 09:18 IST</p>\n        </figcaption>\n      </figure>\n\n      <figure class=\"shrink-0 basis-full snap-start overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n        <div class=\"flex aspect-video items-center justify-center bg-zinc-100\">\n          <i data-lucide=\"image\" class=\"size-6 text-zinc-500\"></i>\n        </div>\n        <figcaption class=\"border-t border-zinc-200 px-4 py-2.5\">\n          <p class=\"truncate text-[13px]/5 font-medium\">Torn bag, third pallet from the tail</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-500 tabular-nums\">gate-inward-03.jpg · 04 Aug 2026 09:24 IST</p>\n        </figcaption>\n      </figure>\n\n      <figure class=\"shrink-0 basis-full snap-start overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n        <div class=\"flex aspect-video items-center justify-center bg-zinc-100\">\n          <i data-lucide=\"image\" class=\"size-6 text-zinc-500\"></i>\n        </div>\n        <figcaption class=\"border-t border-zinc-200 px-4 py-2.5\">\n          <p class=\"truncate text-[13px]/5 font-medium\">Moisture reading at sampling</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-500 tabular-nums\">qc-sample-01.jpg · 04 Aug 2026 10:02 IST</p>\n        </figcaption>\n      </figure>\n    </div>\n\n    <button type=\"button\" @click=\"go(i - 1)\" :disabled=\"atStart\" aria-label=\"Previous photograph\"\n            class=\"absolute left-3 top-1/2 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 shadow-sm hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-0 md:flex\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n    <button type=\"button\" @click=\"go(i + 1)\" :disabled=\"atEnd\" aria-label=\"Next photograph\"\n            class=\"absolute right-3 top-1/2 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-600 shadow-sm hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-0 md:flex\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n\n  <div class=\"mt-2 flex items-center justify-between gap-3\">\n    <p class=\"text-[12px]/4 tabular-nums text-zinc-500\"><span x-text=\"i + 1\"></span> of <span x-text=\"n\"></span> photographs</p>\n    <a href=\"#\" class=\"inline-flex items-center gap-1.5 text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">\n      <i data-lucide=\"download\" class=\"size-3.5\"></i>Download all\n    </a>\n  </div>\n</div>"
        },
        {
          "id": "thumbnails",
          "name": "Thumbnail rail",
          "html": "<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\"\n     class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n\n  <div class=\"flex items-center justify-between gap-3 border-b border-zinc-200 px-5 py-3.5\">\n    <h3 class=\"truncate text-[14px]/5 font-semibold\">Drawings on JOB-24-0338</h3>\n    <p class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">Sheet <span x-text=\"i + 1\"></span> of <span x-text=\"n\"></span></p>\n  </div>\n\n  <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Drawing sheets on JOB-24-0338\"\n       @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n       @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n       class=\"flex snap-x snap-mandatory overflow-x-auto focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n    <div class=\"flex aspect-video shrink-0 basis-full snap-start items-center justify-center bg-zinc-100\">\n      <i data-lucide=\"file-text\" class=\"size-6 text-zinc-500\"></i>\n    </div>\n    <div class=\"flex aspect-video shrink-0 basis-full snap-start items-center justify-center bg-zinc-100\">\n      <i data-lucide=\"file-text\" class=\"size-6 text-zinc-500\"></i>\n    </div>\n    <div class=\"flex aspect-video shrink-0 basis-full snap-start items-center justify-center bg-zinc-100\">\n      <i data-lucide=\"file-text\" class=\"size-6 text-zinc-500\"></i>\n    </div>\n    <div class=\"flex aspect-video shrink-0 basis-full snap-start items-center justify-center bg-zinc-100\">\n      <i data-lucide=\"file-text\" class=\"size-6 text-zinc-500\"></i>\n    </div>\n  </div>\n\n  <div class=\"flex items-center gap-2 border-t border-zinc-200 px-3 py-3\">\n    <button type=\"button\" @click=\"go(i - 1)\" :disabled=\"atStart\" aria-label=\"Previous sheet\"\n            class=\"hidden h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40 md:flex\">\n      <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n    </button>\n\n    <div class=\"flex min-w-0 flex-1 gap-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n      <button type=\"button\" @click=\"go(0)\" :aria-current=\"i === 0 ? 'true' : 'false'\"\n              class=\"shrink-0 rounded-lg border p-1 text-left focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"i === 0 ? 'border-zinc-700 bg-zinc-100' : 'border-zinc-200 bg-white hover:bg-zinc-50'\">\n        <span class=\"flex h-10 w-16 items-center justify-center rounded bg-zinc-200\"><i data-lucide=\"file-text\" class=\"size-3.5 text-zinc-600\"></i></span>\n        <span class=\"mt-1 block w-16 truncate text-[11px]/4 text-zinc-600\">GA-01</span>\n      </button>\n      <button type=\"button\" @click=\"go(1)\" :aria-current=\"i === 1 ? 'true' : 'false'\"\n              class=\"shrink-0 rounded-lg border p-1 text-left focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"i === 1 ? 'border-zinc-700 bg-zinc-100' : 'border-zinc-200 bg-white hover:bg-zinc-50'\">\n        <span class=\"flex h-10 w-16 items-center justify-center rounded bg-zinc-200\"><i data-lucide=\"file-text\" class=\"size-3.5 text-zinc-600\"></i></span>\n        <span class=\"mt-1 block w-16 truncate text-[11px]/4 text-zinc-600\">GA-02</span>\n      </button>\n      <button type=\"button\" @click=\"go(2)\" :aria-current=\"i === 2 ? 'true' : 'false'\"\n              class=\"shrink-0 rounded-lg border p-1 text-left focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"i === 2 ? 'border-zinc-700 bg-zinc-100' : 'border-zinc-200 bg-white hover:bg-zinc-50'\">\n        <span class=\"flex h-10 w-16 items-center justify-center rounded bg-zinc-200\"><i data-lucide=\"file-text\" class=\"size-3.5 text-zinc-600\"></i></span>\n        <span class=\"mt-1 block w-16 truncate text-[11px]/4 text-zinc-600\">DET-01</span>\n      </button>\n      <button type=\"button\" @click=\"go(3)\" :aria-current=\"i === 3 ? 'true' : 'false'\"\n              class=\"shrink-0 rounded-lg border p-1 text-left focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"i === 3 ? 'border-zinc-700 bg-zinc-100' : 'border-zinc-200 bg-white hover:bg-zinc-50'\">\n        <span class=\"flex h-10 w-16 items-center justify-center rounded bg-zinc-200\"><i data-lucide=\"file-text\" class=\"size-3.5 text-zinc-600\"></i></span>\n        <span class=\"mt-1 block w-16 truncate text-[11px]/4 text-zinc-600\">BOM-01</span>\n      </button>\n    </div>\n\n    <button type=\"button\" @click=\"go(i + 1)\" :disabled=\"atEnd\" aria-label=\"Next sheet\"\n            class=\"hidden h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40 md:flex\">\n      <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "stats",
          "name": "Strip on mobile, grid on desktop",
          "html": "<!-- no JavaScript: below sm it is a snapped strip, from sm up it is an ordinary grid -->\n<div role=\"group\" aria-label=\"This month at Silvassa\" tabindex=\"0\"\n     class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 sm:grid sm:grid-cols-2 sm:gap-4 sm:overflow-visible lg:grid-cols-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n\n  <div class=\"shrink-0 basis-[78%] snap-start rounded-xl border border-zinc-200 bg-white p-4 sm:basis-auto\">\n    <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Orders raised</p>\n    <p class=\"mt-1.5 text-[24px]/8 font-semibold tabular-nums\">128</p>\n    <p class=\"mt-1 text-[12px]/4 text-zinc-600 tabular-nums\">14 more than July</p>\n  </div>\n\n  <div class=\"shrink-0 basis-[78%] snap-start rounded-xl border border-zinc-200 bg-white p-4 sm:basis-auto\">\n    <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Committed value</p>\n    <p class=\"mt-1.5 text-[24px]/8 font-semibold tabular-nums\">₹2.41 Cr</p>\n    <p class=\"mt-1 text-[12px]/4 text-zinc-600 tabular-nums\">Against a ₹2.75 Cr budget</p>\n  </div>\n\n  <div class=\"shrink-0 basis-[78%] snap-start rounded-xl border border-zinc-200 bg-white p-4 sm:basis-auto\">\n    <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Overdue deliveries</p>\n    <p class=\"mt-1.5 flex items-center gap-2 text-[24px]/8 font-semibold tabular-nums\">\n      <span class=\"size-2 rounded-full bg-red-600\" aria-hidden=\"true\"></span>7\n    </p>\n    <p class=\"mt-1 text-[12px]/4 text-zinc-600 tabular-nums\">Oldest is 19 days late</p>\n  </div>\n\n  <div class=\"shrink-0 basis-[78%] snap-start rounded-xl border border-zinc-200 bg-white p-4 sm:basis-auto\">\n    <p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Awaiting approval</p>\n    <p class=\"mt-1.5 text-[24px]/8 font-semibold tabular-nums\">12</p>\n    <p class=\"mt-1 text-[12px]/4 text-zinc-600 tabular-nums\">4 sitting over a week</p>\n  </div>\n</div>"
        },
        {
          "id": "empty",
          "name": "Empty",
          "html": "<div>\n  <h3 class=\"mb-3 text-[14px]/5 font-semibold\">Photographs</h3>\n  <div class=\"rounded-xl border border-dashed border-zinc-200 bg-white px-6 py-10 text-center\">\n    <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n      <i data-lucide=\"image\" class=\"size-4 text-zinc-600\"></i>\n    </span>\n    <p class=\"mt-3 text-[14px]/5 font-medium\">No photographs on this inspection</p>\n    <p class=\"mx-auto mt-1 max-w-sm text-[13px]/5 text-zinc-600\">\n      Photos taken at the gate appear here in the order they were uploaded, newest last.\n    </p>\n    <button type=\"button\" class=\"mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800 focus-visible:ring-3 focus-visible:ring-zinc-700/30\">\n      <i data-lucide=\"upload\" class=\"size-4\"></i>Add photographs\n    </button>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Django template",
          "html": "{% if inspection.photos.exists %}\n<div x-data=\"{\n       i: 0, n: 0, atStart: true, atEnd: true,\n       slides() { return Array.from(this.$refs.rail.children).filter(el => el.localName !== 'template'); },\n       pad() { return parseFloat(getComputedStyle(this.$refs.rail).scrollPaddingLeft) || 0; },\n       sync() {\n         const r = this.$refs.rail, s = this.slides(), edge = r.getBoundingClientRect().left + this.pad();\n         this.n = s.length;\n         this.atStart = r.scrollLeft < 2;\n         this.atEnd = r.scrollLeft > r.scrollWidth - r.clientWidth - 2;\n         const near = s.map((el, k) => [Math.abs(el.getBoundingClientRect().left - edge), k]).sort((a, b) => a[0] - b[0]);\n         this.i = near.length ? near[0][1] : 0;\n       },\n       go(k) {\n         const r = this.$refs.rail, s = this.slides(), el = s[Math.min(Math.max(k, 0), s.length - 1)];\n         if (!el) return;\n         r.scrollBy({ left: el.getBoundingClientRect().left - r.getBoundingClientRect().left - this.pad(),\n                      behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });\n       }\n     }\"\n     x-init=\"sync()\"\n     @resize.window.debounce=\"sync()\">\n\n  <div class=\"mb-3 flex items-center justify-between gap-3\">\n    <h3 class=\"text-[14px]/5 font-semibold\">Photographs</h3>\n    <div class=\"flex items-center gap-2\">\n      <p class=\"text-[12px]/4 tabular-nums text-zinc-500\"><span x-text=\"i + 1\"></span> of <span x-text=\"n\"></span></p>\n      <div class=\"hidden items-center gap-1 md:flex\">\n        <button type=\"button\" @click=\"go(i - 1)\" :disabled=\"atStart\" aria-label=\"Previous photograph\"\n                class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n          <i data-lucide=\"chevron-left\" class=\"size-4\"></i>\n        </button>\n        <button type=\"button\" @click=\"go(i + 1)\" :disabled=\"atEnd\" aria-label=\"Next photograph\"\n                class=\"flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-700/15 disabled:pointer-events-none disabled:opacity-40\">\n          <i data-lucide=\"chevron-right\" class=\"size-4\"></i>\n        </button>\n      </div>\n    </div>\n  </div>\n\n  {# the loop is server side, so there is no x-for template for slides() to filter out #}\n  <div x-ref=\"rail\" @scroll.passive=\"sync()\" tabindex=\"0\" role=\"group\" aria-label=\"Photographs on inspection {{ inspection.reference }}\"\n       @keydown.arrow-right=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i + 1); }\"\n       @keydown.arrow-left=\"if ($event.target === $refs.rail) { $event.preventDefault(); go(i - 1); }\"\n       class=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n    {% for photo in inspection.photos.all %}\n      <figure class=\"shrink-0 basis-full snap-start overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n        <img src=\"{{ photo.image.url }}\" alt=\"{{ photo.caption }}\" loading=\"lazy\" decoding=\"async\"\n             class=\"aspect-video w-full bg-zinc-100 object-cover\">\n        <figcaption class=\"border-t border-zinc-200 px-4 py-2.5\">\n          <p class=\"truncate text-[13px]/5 font-medium\">{{ photo.caption }}</p>\n          <p class=\"mt-0.5 truncate text-[12px]/4 text-zinc-500 tabular-nums\">\n            {{ photo.filename }} · {{ photo.taken_at|date:\"d M Y H:i\" }} IST\n          </p>\n        </figcaption>\n      </figure>\n    {% endfor %}\n  </div>\n</div>\n{% else %}\n<div class=\"rounded-xl border border-dashed border-zinc-200 bg-white px-6 py-10 text-center\">\n  <span class=\"mx-auto flex size-10 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n    <i data-lucide=\"image\" class=\"size-4 text-zinc-600\"></i>\n  </span>\n  <p class=\"mt-3 text-[14px]/5 font-medium\">No photographs on this inspection</p>\n  <p class=\"mx-auto mt-1 max-w-sm text-[13px]/5 text-zinc-600\">Photos taken at the gate appear here in the order they were uploaded.</p>\n</div>\n{% endif %}"
        }
      ]
    },
    {
      "id": "list-detail",
      "name": "List and detail",
      "category": "layout",
      "description": "A scrolling list on the left and the selected record on the right. Picking a row swaps the right pane without leaving the page.",
      "when_to_use": "Triage work where you read many records in a row — approvals, receipts, open orders. Use a plain table instead when people come to compare rows, not to work through them.",
      "rules": [
        "Below lg only one pane is on screen. Bind both panes with :class rather than x-show — x-show writes an inline display:none that beats lg:block, so the detail pane would never come back on a wide screen.",
        "The Back button is the only way out of the detail on mobile. Keep it lg:hidden and keep it first in the detail header.",
        "The list pane gets its own max-h and overflow-y-auto so it scrolls independently of the page.",
        "Selecting a row must not change the page URL for the list. If the detail deserves its own URL, this is two pages, not this pattern."
      ],
      "anatomy": [
        {
          "part": "List pane",
          "description": "Left, with its own max-h and overflow-y-auto so it scrolls independently of the page."
        },
        {
          "part": "Row",
          "description": "One record, tinted zinc-100 when it is the selected one."
        },
        {
          "part": "Detail pane",
          "description": "Right, showing the selected record in full."
        },
        {
          "part": "Back button",
          "description": "lg:hidden and first in the detail header. On mobile it is the only way out of the detail."
        },
        {
          "part": "Selection state",
          "description": "The selected id on the component root; both panes bind to it."
        }
      ],
      "behaviour": [
        "Picking a row swaps the right pane without leaving the page, which is the whole reason to choose this over a table.",
        "Below lg only one pane is on screen at a time and the Back button returns to the list.",
        "Both panes bind with :class, never x-show — x-show writes an inline display:none that beats lg:block, so the detail pane would never come back on a wide screen.",
        "Selecting a row does not change the URL. If the detail deserves its own URL, this is two pages and not this pattern.",
        "The list keeps its scroll position when the detail changes, so working down a queue does not reset it."
      ],
      "accessibility": [
        "Rows are buttons or links, not clickable divs, so the list is walkable by keyboard.",
        "The selected row carries aria-current, so position is announced and not only tinted.",
        "Choosing a row moves focus into the detail pane on mobile, where the list is no longer visible.",
        "The Back button is a real button with a text label, not a bare chevron.",
        "The detail pane is a labelled region so it can be jumped to directly."
      ],
      "related": [
        "table",
        "drawer",
        "app-shell"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{\n       sel: 0,\n       detail: false,\n       orders: [\n         { po: 'PO-24-1187', vendor: 'Sharma Steel &amp; Alloys', dept: 'Fabrication', value: '₹18,42,000', lines: 9, due: '22 Aug', raised: '04 Aug 2026', status: 'Overdue', pct: 62, buyer: 'Akshay Prabhu', grn: '4 of 9 lines received' },\n         { po: 'PO-24-1191', vendor: 'Gujarat Polymers Ltd', dept: 'Moulding', value: '₹7,15,600', lines: 4, due: '28 Aug', raised: '07 Aug 2026', status: 'Open', pct: 25, buyer: 'Meera Joshi', grn: '1 of 4 lines received' },\n         { po: 'PO-24-1194', vendor: 'Deccan Bearings Pvt Ltd', dept: 'Maintenance', value: '₹2,84,300', lines: 12, due: '01 Sep', raised: '09 Aug 2026', status: 'Approved', pct: 0, buyer: 'Akshay Prabhu', grn: 'Nothing received yet' },\n         { po: 'PO-24-1198', vendor: 'Konkan Packaging Co', dept: 'Dispatch', value: '₹1,09,750', lines: 3, due: '18 Aug', raised: '11 Aug 2026', status: 'Closed', pct: 100, buyer: 'Nilesh Patil', grn: 'All 3 lines received' }\n       ]\n     }\">\n  <div class=\"grid lg:grid-cols-[minmax(0,1fr)_minmax(0,400px)]\">\n\n    <!-- list pane -->\n    <div class=\"min-w-0 border-zinc-200 lg:border-r\" :class=\"detail ? 'hidden lg:block' : 'block'\">\n      <div class=\"flex items-center gap-2 border-b border-zinc-200 px-4 py-3\">\n        <div class=\"flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <i data-lucide=\"search\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n          <input aria-label=\"Search orders\" placeholder=\"Search orders or vendors\" class=\"w-full bg-transparent text-[13px]/5 outline-none placeholder:text-zinc-500\">\n        </div>\n        <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-600\">4 open</span>\n      </div>\n\n      <div class=\"max-h-[460px] overflow-y-auto\">\n        <template x-for=\"(o, i) in orders\" :key=\"o.po\">\n          <button @click=\"sel = i; detail = true\"\n                  class=\"flex w-full gap-3 border-b border-zinc-100 px-4 py-3 text-left hover:bg-zinc-100\"\n                  :class=\"sel === i && 'bg-zinc-100/70 shadow-[inset_3px_0_0_0_var(--color-zinc-700)]'\">\n            <span class=\"mt-1.5 size-2 shrink-0 rounded-full\"\n                  :class=\"{ 'bg-red-600': o.status === 'Overdue', 'bg-zinc-500': o.status === 'Open', 'bg-amber-500': o.status === 'Approved', 'bg-emerald-600': o.status === 'Closed' }\"></span>\n            <span class=\"min-w-0 flex-1\">\n              <span class=\"flex items-baseline justify-between gap-2\">\n                <span class=\"truncate text-[14px]/5 font-medium\" x-text=\"o.vendor\"></span>\n                <span class=\"shrink-0 text-[13px]/5 font-medium tabular-nums\" x-text=\"o.value\"></span>\n              </span>\n              <span class=\"mt-0.5 block text-[12px]/4 tabular-nums text-zinc-600\" x-text=\"o.po + ' · ' + o.lines + ' lines · ' + o.dept\"></span>\n              <span class=\"mt-1.5 flex items-center gap-2\">\n                <!-- one class for every state; the colour is already in the dot above -->\n                <span class=\"rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\"\n                      x-text=\"o.status\"></span>\n                <span class=\"text-[11px]/4 text-zinc-500\" x-text=\"'due ' + o.due\"></span>\n              </span>\n            </span>\n          </button>\n        </template>\n      </div>\n    </div>\n\n    <!-- detail pane -->\n    <div class=\"min-w-0\" :class=\"detail ? 'block' : 'hidden lg:block'\">\n      <div class=\"border-b border-zinc-200 px-5 py-4\">\n        <button @click=\"detail = false\" class=\"mb-3 flex items-center gap-1.5 text-[13px]/5 font-medium text-zinc-600 hover:text-zinc-900 lg:hidden\">\n          <i data-lucide=\"chevron-left\" class=\"size-4\"></i>Back to orders\n        </button>\n        <p class=\"text-[12px]/4 tabular-nums text-zinc-600\" x-text=\"orders[sel].po\"></p>\n        <h3 class=\"mt-0.5 text-[16px]/6 font-semibold\" x-text=\"orders[sel].vendor\"></h3>\n        <p class=\"mt-0.5 text-[12px]/4 text-zinc-600\" x-text=\"orders[sel].dept + ' · raised ' + orders[sel].raised + ' by ' + orders[sel].buyer\"></p>\n        <div class=\"mt-3 flex gap-2\">\n          <button class=\"flex-1 rounded-lg border border-zinc-200 px-3 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Open full record</button>\n          <button class=\"flex-1 rounded-lg bg-zinc-700 px-3 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Record GRN</button>\n        </div>\n      </div>\n\n      <div class=\"grid grid-cols-3 divide-x divide-zinc-200 border-b border-zinc-200 text-center\">\n        <div class=\"px-2 py-3\"><p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Value</p><p class=\"mt-1 text-[13px]/5 font-semibold tabular-nums\" x-text=\"orders[sel].value\"></p></div>\n        <div class=\"px-2 py-3\"><p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Lines</p><p class=\"mt-1 text-[13px]/5 font-semibold tabular-nums\" x-text=\"orders[sel].lines\"></p></div>\n        <div class=\"px-2 py-3\"><p class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">Due</p><p class=\"mt-1 text-[13px]/5 font-semibold tabular-nums\" x-text=\"orders[sel].due\"></p></div>\n      </div>\n\n      <div class=\"border-b border-zinc-200 px-5 py-4\">\n        <div class=\"flex items-center justify-between text-[12px]/4\">\n          <span class=\"text-zinc-600\" x-text=\"orders[sel].grn\"></span>\n          <span class=\"font-medium tabular-nums\" x-text=\"orders[sel].pct + '%'\"></span>\n        </div>\n        <div class=\"mt-2 h-2 overflow-hidden rounded-full bg-zinc-100\">\n          <div class=\"h-full rounded-full bg-zinc-700 transition-all\" :style=\"'width:' + orders[sel].pct + '%'\"></div>\n        </div>\n      </div>\n\n      <div class=\"px-5 py-4\">\n        <p class=\"text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\">Line items</p>\n        <table class=\"mt-3 w-full text-left text-[13px]/5\">\n          <thead class=\"text-[11px]/4 uppercase tracking-wider text-zinc-500\">\n            <tr>\n              <th scope=\"col\" class=\"pb-2 font-medium\">Material</th>\n              <th scope=\"col\" class=\"pb-2 text-right font-medium\">Qty</th>\n              <th scope=\"col\" class=\"pb-2 text-right font-medium\">Amount</th>\n            </tr>\n          </thead>\n          <tbody>\n            <tr class=\"border-t border-zinc-100\"><td class=\"py-2\">MS Angle 50×50×6</td><td class=\"py-2 text-right tabular-nums text-zinc-600\">420 kg</td><td class=\"py-2 text-right font-medium tabular-nums\">₹26,208</td></tr>\n            <tr class=\"border-t border-zinc-100\"><td class=\"py-2\">MS Plate 8 mm</td><td class=\"py-2 text-right tabular-nums text-zinc-600\">180 kg</td><td class=\"py-2 text-right font-medium tabular-nums\">₹12,798</td></tr>\n            <tr class=\"border-t border-zinc-100\"><td class=\"py-2\">Hex bolt M12×60</td><td class=\"py-2 text-right tabular-nums text-zinc-600\">1,200 nos</td><td class=\"py-2 text-right font-medium tabular-nums\">₹14,220</td></tr>\n          </tbody>\n        </table>\n      </div>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "form-page",
      "name": "Form page",
      "category": "layout",
      "description": "The layout a create or edit screen uses: fields in a card, and the Save and Cancel actions where the user can always reach them.",
      "when_to_use": "Any create or edit screen with more than about four fields. A short form belongs in a modal instead.",
      "rules": [
        "Save is on the right, Cancel to its left. Never put a destructive action in the same cluster — delete lives on the record, not in the editor.",
        "The sticky footer needs a solid bg-white and a top border, otherwise fields scroll under it and read as clipped.",
        "Two columns are for short related fields. Anything long — descriptions, addresses, line tables — spans both with sm:col-span-2.",
        "The side rail is context, never a control. If the user has to act on it, it belongs in the form."
      ],
      "anatomy": [
        {
          "part": "Header",
          "description": "The page title and what is being edited."
        },
        {
          "part": "Field grid",
          "description": "Two columns for short related fields; anything long spans both with sm:col-span-2."
        },
        {
          "part": "Side rail",
          "description": "Context only — who raised it, when, what it links to. Never a control."
        },
        {
          "part": "Sticky footer",
          "description": "Save and Cancel, on a solid white bar with a top border so fields do not read as clipped beneath it."
        },
        {
          "part": "Error summary",
          "description": "Above the fields when a submit fails, linking to each field that needs attention."
        }
      ],
      "behaviour": [
        "Save sits right with Cancel to its left. A destructive action never joins that cluster — delete lives on the record, not in the editor.",
        "The footer is sticky and opaque. A transparent one lets fields scroll under it and look cut off.",
        "Long content spans both columns. A description squeezed into one column of a two-column grid is unreadable.",
        "The side rail is context. The moment the user has to act on it, it belongs in the form.",
        "A failed submit returns the user to the top with a summary, and every entered value is still there."
      ],
      "accessibility": [
        "The form is a real <form> with a submit button, so Enter submits from any field.",
        "The error summary takes focus when it appears and each entry links to its field's id.",
        "The field grid follows DOM order, so the visual order and the tab order match.",
        "The sticky footer does not overlap the last field when focused, or keyboard users cannot see what they are typing.",
        "Required fields are marked in the label and backed by the required attribute."
      ],
      "related": [
        "field",
        "modal",
        "page-header"
      ],
      "variants": [
        {
          "id": "two-column",
          "name": "Two columns with sticky actions",
          "html": "<form class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n  <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n    <h2 class=\"text-[14px]/5 font-semibold\">New purchase order</h2>\n    <p class=\"text-[12px]/4 text-zinc-600\">Fields marked <span class=\"text-red-600\">*</span> are required</p>\n  </div>\n\n  <div class=\"max-h-[420px] overflow-y-auto px-5 py-5\">\n    <div class=\"grid gap-4 sm:grid-cols-2\">\n      <div>\n        <label for=\"fp-vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor <span class=\"text-red-600\">*</span></label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <select id=\"fp-vendor\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n            <option>Sharma Steel &amp; Alloys</option>\n            <option>Gujarat Polymers Ltd</option>\n            <option>Deccan Bearings Pvt Ltd</option>\n          </select>\n        </div>\n      </div>\n      <div>\n        <label for=\"fp-dept\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Cost centre <span class=\"text-red-600\">*</span></label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <select id=\"fp-dept\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n            <option>Fabrication — Silvassa</option>\n            <option>Moulding — Silvassa</option>\n            <option>Maintenance — Vapi</option>\n          </select>\n        </div>\n      </div>\n      <div>\n        <label for=\"fp-need\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Required by <span class=\"text-red-600\">*</span></label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <input id=\"fp-need\" type=\"date\" value=\"2026-09-04\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 tabular-nums outline-none\">\n        </div>\n      </div>\n      <div>\n        <label for=\"fp-value\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Estimated value</label>\n        <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <span class=\"pl-3 text-[14px]/5 text-zinc-600\">₹</span>\n          <input id=\"fp-value\" value=\"18,42,000\" class=\"w-full bg-transparent px-2 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n        </div>\n        <p class=\"mt-1.5 text-[12px]/4 text-zinc-500\">Above ₹5,00,000 this needs a second approver.</p>\n      </div>\n      <div class=\"sm:col-span-2\">\n        <label for=\"fp-title\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Order title <span class=\"text-red-600\">*</span></label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <input id=\"fp-title\" value=\"MS angles and plates — August lot\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n        </div>\n      </div>\n      <div class=\"sm:col-span-2\">\n        <label for=\"fp-ship\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Delivery address</label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <textarea id=\"fp-ship\" rows=\"3\" class=\"w-full resize-y bg-transparent px-3 py-2 text-[14px]/5 outline-none\">Konspec Industries, Plot 214, Silvassa Industrial Estate, Dadra &amp; Nagar Haveli 396230</textarea>\n        </div>\n      </div>\n      <div class=\"sm:col-span-2\">\n        <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n          <input type=\"checkbox\" checked class=\"mt-0.5 size-4 rounded accent-zinc-700\">\n          <span>Email the vendor when this order is approved\n            <span class=\"mt-0.5 block text-[12px]/4 text-zinc-500\">Goes to purchase@sharmasteel.in</span>\n          </span>\n        </label>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"sticky bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-white px-5 py-3\">\n    <p class=\"text-[12px]/4 text-zinc-500\">Last saved as draft 14:02</p>\n    <div class=\"flex items-center gap-2\">\n      <button type=\"button\" class=\"rounded-lg px-4 py-2 text-[13px]/5 font-medium text-zinc-900 hover:bg-zinc-100\">Cancel</button>\n      <button type=\"button\" class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Save draft</button>\n      <button type=\"submit\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Send for approval</button>\n    </div>\n  </div>\n</form>"
        },
        {
          "id": "side-rail",
          "name": "With help rail",
          "html": "<form class=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_300px]\">\n  <div class=\"overflow-hidden rounded-xl border border-zinc-200 bg-white\">\n    <div class=\"border-b border-zinc-200 px-5 py-3.5\">\n      <h2 class=\"text-[14px]/5 font-semibold\">Rate contract</h2>\n      <p class=\"text-[12px]/4 text-zinc-600\">Gujarat Polymers Ltd · V-0412</p>\n    </div>\n    <div class=\"space-y-4 px-5 py-5\">\n      <div>\n        <label for=\"rc-material\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Material <span class=\"text-red-600\">*</span></label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <select id=\"rc-material\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n            <option>HDPE granules — natural</option>\n            <option>PP copolymer — black</option>\n          </select>\n        </div>\n      </div>\n      <div class=\"grid gap-4 sm:grid-cols-2\">\n        <div>\n          <label for=\"rc-rate\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Rate per kg <span class=\"text-red-600\">*</span></label>\n          <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <span class=\"pl-3 text-[14px]/5 text-zinc-600\">₹</span>\n            <input id=\"rc-rate\" value=\"118.40\" class=\"w-full bg-transparent px-2 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n        <div>\n          <label for=\"rc-qty\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Committed quantity</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <input id=\"rc-qty\" value=\"45,000 kg\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n        <div>\n          <label for=\"rc-from\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Valid from</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <input id=\"rc-from\" type=\"date\" value=\"2026-09-01\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n        <div>\n          <label for=\"rc-to\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Valid to</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <input id=\"rc-to\" type=\"date\" value=\"2027-03-31\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n      </div>\n      <div>\n        <label for=\"rc-notes\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Notes for the buyer</label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <textarea id=\"rc-notes\" rows=\"3\" class=\"w-full resize-y bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\" placeholder=\"Escalation contact, packing requirement, test certificate\"></textarea>\n        </div>\n      </div>\n    </div>\n    <div class=\"flex items-center justify-end gap-2 border-t border-zinc-200 px-5 py-3\">\n      <button type=\"button\" class=\"rounded-lg px-4 py-2 text-[13px]/5 font-medium text-zinc-900 hover:bg-zinc-100\">Cancel</button>\n      <button type=\"submit\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Save contract</button>\n    </div>\n  </div>\n\n  <aside class=\"space-y-4\">\n    <div class=\"rounded-xl border border-zinc-200 bg-white px-4 py-4\">\n      <h3 class=\"text-[13px]/5 font-semibold\">How rates are applied</h3>\n      <p class=\"mt-1.5 text-[13px]/5 text-zinc-600\">\n        A purchase order raised inside the validity window picks this rate automatically. Buyers can override it, and the override is logged against the order.\n      </p>\n    </div>\n    <div class=\"rounded-xl border border-zinc-200 bg-white px-4 py-4\">\n      <h3 class=\"text-[13px]/5 font-semibold\">Last three months</h3>\n      <dl class=\"mt-3 space-y-2.5 text-[13px]/5\">\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-zinc-600\">Average rate paid</dt><dd class=\"font-medium tabular-nums\">₹121.75</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-zinc-600\">Quantity bought</dt><dd class=\"font-medium tabular-nums\">38,200 kg</dd>\n        </div>\n        <div class=\"flex items-baseline justify-between gap-3\">\n          <dt class=\"text-zinc-600\">On-time delivery</dt><dd class=\"font-medium tabular-nums\">91%</dd>\n        </div>\n      </dl>\n    </div>\n    <!-- neutral body, colour only in the icon — same as the alert component -->\n    <div class=\"flex items-start gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3\">\n      <i data-lucide=\"alert-triangle\" class=\"mt-px size-4 shrink-0 text-amber-700\"></i>\n      <p class=\"text-[13px]/5 text-zinc-700\">The existing contract for HDPE granules runs to 31 Aug 2026. Saving this one supersedes it.</p>\n    </div>\n  </aside>\n</form>"
        }
      ]
    },
    {
      "id": "auth-page",
      "name": "Auth page",
      "category": "layout",
      "description": "The signed-out screen: one centred card, the fewest fields that will do, and nothing else on the page.",
      "when_to_use": "Sign in, password reset, set a new password. No sidebar, no topbar, no search.",
      "rules": [
        "This is the shape of Django registration/login.html. The error block maps to {{ form.non_field_errors }}, the fields to {{ form.username }} and {{ form.password }}, and the form needs {% csrf_token %} plus a hidden next input.",
        "Never say which of the two was wrong. One message for a bad email and a bad password.",
        "Autofocus the first field and set autocomplete — email on the username, current-password on the password — or password managers will not fill it.",
        "No sign-up link. Konspec accounts are created by IT, so the dead end is the support line, not a register page."
      ],
      "anatomy": [
        {
          "part": "Card",
          "description": "One centred panel, and nothing else on the page."
        },
        {
          "part": "Error block",
          "description": "Above the fields. Maps to {{ form.non_field_errors }}."
        },
        {
          "part": "Fields",
          "description": "The fewest that will do, with autocomplete set so password managers can fill them."
        },
        {
          "part": "Submit",
          "description": "Full width. There is only one action on this page."
        },
        {
          "part": "Support link",
          "description": "The dead end when sign-in fails. There is no sign-up link, because accounts are created by IT."
        }
      ],
      "behaviour": [
        "Never say which of the two was wrong. One message covers a bad email and a bad password.",
        "The first field is autofocused, so the user can start typing immediately.",
        "autocomplete is set — email on the username, current-password on the password — or password managers will not fill it.",
        "No shell: no sidebar, no topbar, no search. The page has exactly one job.",
        "This is the shape of Django's registration/login.html, and the form posts with {% csrf_token %}."
      ],
      "accessibility": [
        "Both fields have real labels, not placeholders.",
        "The error block is role=\"alert\" so a failed attempt is announced, since the page otherwise looks unchanged.",
        "The submit button is a real submit, so Enter works from either field.",
        "Autofocus lands on the first field and does not steal focus later.",
        "The support link is reachable by keyboard and reads as a destination, not as decoration."
      ],
      "related": [
        "field",
        "form-page",
        "error-page"
      ],
      "variants": [
        {
          "id": "signin",
          "name": "Sign in",
          "html": "<div class=\"flex min-h-[560px] items-center justify-center bg-zinc-100 px-4 py-10\">\n  <div class=\"w-full max-w-sm\">\n    <div class=\"flex items-center justify-center gap-2.5\">\n      <span class=\"flex size-9 items-center justify-center rounded-lg bg-zinc-700 text-[14px]/5 font-semibold text-white\">K</span>\n      <span class=\"text-[16px]/6 font-semibold\">Konspec Operations</span>\n    </div>\n\n    <form class=\"mt-6 rounded-xl border border-zinc-200 bg-white px-6 py-6\">\n      <h1 class=\"text-[20px]/7 font-semibold tracking-tight\">Sign in</h1>\n      <p class=\"mt-1 text-[13px]/5 text-zinc-600\">Use your Konspec Industries email address.</p>\n\n      <!-- neutral body, colour only in the icon. A red field behind red text is\n           harder to read than the message is urgent. -->\n      <div class=\"mt-4 flex items-start gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2.5\">\n        <i data-lucide=\"alert-circle\" class=\"mt-px size-4 shrink-0 text-red-600\"></i>\n        <p class=\"text-[13px]/5 text-zinc-700\">That email and password do not match an active account. Two attempts left before the account is locked for 15 minutes.</p>\n      </div>\n\n      <div class=\"mt-4\">\n        <label for=\"auth-email\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Work email</label>\n        <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n          <input id=\"auth-email\" type=\"email\" autocomplete=\"email\" autofocus value=\"akshay.prabhu@konspec.com\"\n                 class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n        </div>\n      </div>\n\n      <div class=\"mt-3.5\">\n        <div class=\"mb-1.5 flex items-baseline justify-between gap-3\">\n          <label for=\"auth-password\" class=\"text-[13px]/5 font-medium\">Password</label>\n          <a href=\"#\" class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Forgot password</a>\n        </div>\n        <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\"\n             x-data=\"{ show: false }\">\n          <input id=\"auth-password\" :type=\"show ? 'text' : 'password'\" autocomplete=\"current-password\" value=\"0000000000\"\n                 class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n          <button type=\"button\" @click=\"show = !show\" class=\"px-3 text-zinc-600 hover:text-zinc-900\"\n                  :aria-label=\"show ? 'Hide password' : 'Show password'\">\n            <span x-show=\"!show\"><i data-lucide=\"eye\" class=\"size-4\"></i></span>\n            <span x-show=\"show\" x-cloak><i data-lucide=\"eye-off\" class=\"size-4\"></i></span>\n          </button>\n        </div>\n      </div>\n\n      <label class=\"mt-4 flex items-center gap-2.5 text-[13px]/5\">\n        <input type=\"checkbox\" checked class=\"size-4 rounded accent-zinc-700\">Keep me signed in on this device\n      </label>\n\n      <button type=\"submit\" class=\"mt-5 w-full rounded-lg bg-zinc-700 px-4 py-2.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Sign in</button>\n\n      <p class=\"mt-4 border-t border-zinc-100 pt-4 text-[12px]/4 text-zinc-600\">\n        No account yet? Accounts are created by IT.\n        <a href=\"mailto:it@konspec.com\" class=\"font-medium text-zinc-900 underline underline-offset-2\">Email it@konspec.com</a>\n      </p>\n    </form>\n\n    <p class=\"mt-4 text-center text-[11px]/4 text-zinc-500\">Konspec Industries · internal system · activity is logged</p>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "error-page",
      "name": "Error page",
      "category": "layout",
      "description": "What the user sees when the page cannot be shown. Says what happened, what it means for them, and gives one way back.",
      "when_to_use": "404, 403 and 500 handlers. For a failure inside a page that otherwise works, use an inline empty or error state instead.",
      "rules": [
        "One primary action back to somewhere that works, one secondary link to support. No search box, no illustration.",
        "Show the reference the support team needs — the requested path on a 404, the incident id on a 500. Never show a traceback.",
        "On 403 say which permission is missing and who grants it, otherwise the user has nowhere to go.",
        "In Django these are templates/404.html, 403.html and 500.html. 500.html renders with no context processors, so it can use no template variables at all."
      ],
      "anatomy": [
        {
          "part": "Code",
          "description": "The status, small and muted. It is for the support call, not for the user."
        },
        {
          "part": "Headline",
          "description": "What happened, in the user's terms. \"This page does not exist\", not \"404 Not Found\"."
        },
        {
          "part": "Explanation",
          "description": "What it means for them and what to do about it."
        },
        {
          "part": "Reference",
          "description": "The requested path on a 404, the incident id on a 500. Never a traceback."
        },
        {
          "part": "Actions",
          "description": "One primary way back to somewhere that works, one secondary link to support."
        }
      ],
      "behaviour": [
        "One way back and one way to ask for help. No search box, no illustration.",
        "A 403 names the missing permission and who grants it, or the user has nowhere to go.",
        "A 500 shows an incident id the support team can look up, and nothing about the internals.",
        "These are whole-page handlers. A failure inside a page that otherwise works is an inline empty or error state.",
        "Django's 500.html renders with no context processors, so it can use no template variables at all — every value on it must be literal."
      ],
      "accessibility": [
        "The headline is the page's h1, so the page has a proper title in the outline.",
        "The status code is not the heading; it is supporting detail beside a sentence in words.",
        "The primary action is a real link to a working page, not a history-back button that may loop.",
        "Colour is not used to convey severity — the wording does that.",
        "The reference is selectable text so it can be copied into a support ticket."
      ],
      "related": [
        "empty-state",
        "alert",
        "auth-page"
      ],
      "variants": [
        {
          "id": "404",
          "name": "404 not found",
          "html": "<div class=\"flex min-h-[420px] items-center justify-center bg-zinc-200 px-4 py-12\">\n  <div class=\"max-w-md text-center\">\n    <span class=\"mx-auto flex size-12 items-center justify-center rounded-full bg-white ring-1 ring-zinc-300\">\n      <i data-lucide=\"file-question\" class=\"size-5 text-zinc-600\"></i>\n    </span>\n    <p class=\"mt-4 text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\">Error 404</p>\n    <h1 class=\"mt-1 text-[20px]/7 font-semibold tracking-tight\">This page does not exist</h1>\n    <p class=\"mt-2 text-[14px]/5 text-zinc-600\">\n      Nothing is served at <span class=\"font-medium text-zinc-900\">/orders/PO-24-9910/</span>. The order may have been deleted, or the link may be out of date.\n    </p>\n    <div class=\"mt-6 flex flex-wrap items-center justify-center gap-2\">\n      <a href=\"#\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Back to purchase orders</a>\n      <a href=\"mailto:it@konspec.com\" class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-200\">Contact support</a>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "403",
          "name": "403 permission denied",
          "html": "<div class=\"flex min-h-[420px] items-center justify-center bg-zinc-200 px-4 py-12\">\n  <div class=\"max-w-md text-center\">\n    <span class=\"mx-auto flex size-12 items-center justify-center rounded-full bg-white ring-1 ring-zinc-300\">\n      <i data-lucide=\"lock\" class=\"size-5 text-zinc-600\"></i>\n    </span>\n    <p class=\"mt-4 text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\">Error 403</p>\n    <h1 class=\"mt-1 text-[20px]/7 font-semibold tracking-tight\">You cannot open this order</h1>\n    <p class=\"mt-2 text-[14px]/5 text-zinc-600\">\n      PO-24-1187 belongs to the Fabrication cost centre. Your account has read access to Moulding and Dispatch only.\n    </p>\n    <p class=\"mt-3 text-[13px]/5 text-zinc-600\">\n      Access is granted by Nilesh Patil, Head of Procurement.\n    </p>\n    <div class=\"mt-6 flex flex-wrap items-center justify-center gap-2\">\n      <a href=\"#\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Back to overview</a>\n      <a href=\"mailto:it@konspec.com\" class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-200\">Request access</a>\n    </div>\n    <p class=\"mt-6 text-[11px]/4 text-zinc-500\">Signed in as akshay.prabhu@konspec.com</p>\n  </div>\n</div>"
        },
        {
          "id": "500",
          "name": "500 server error",
          "html": "<div class=\"flex min-h-[420px] items-center justify-center bg-zinc-100 px-4 py-12\">\n  <div class=\"max-w-md text-center\">\n    <span class=\"mx-auto flex size-12 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300\">\n      <i data-lucide=\"alert-circle\" class=\"size-5 text-red-600\"></i>\n    </span>\n    <p class=\"mt-4 text-[11px]/4 font-semibold uppercase tracking-wider text-zinc-500\">Error 500</p>\n    <h1 class=\"mt-1 text-[20px]/7 font-semibold tracking-tight\">Something broke on our side</h1>\n    <p class=\"mt-2 text-[14px]/5 text-zinc-600\">\n      The page could not be built. Nothing you did was saved, so it is safe to try again in a minute.\n    </p>\n    <p class=\"mt-4 inline-block rounded-lg bg-white px-3 py-1.5 text-[12px]/4 tabular-nums text-zinc-600 ring-1 ring-zinc-200\">\n      Reference INC-8342 · 19 Aug 2026 14:07 IST\n    </p>\n    <div class=\"mt-6 flex flex-wrap items-center justify-center gap-2\">\n      <a href=\"#\" class=\"rounded-lg bg-zinc-700 px-4 py-2 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Back to overview</a>\n      <a href=\"mailto:it@konspec.com\" class=\"rounded-lg border border-zinc-200 bg-white px-4 py-2 text-[13px]/5 font-medium hover:bg-zinc-100\">Contact support</a>\n    </div>\n    <p class=\"mt-6 text-[11px]/4 text-zinc-500\">Quote the reference and IT can find the exact failure.</p>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "tabs",
      "name": "Tabs",
      "category": "navigation",
      "description": "Switches between views of the same record without leaving the page. The panel below changes; the row above is a widget, not a menu, and the keyboard has to know the difference.",
      "when_to_use": "Two to seven sibling views of one thing — an order and its lines, receipts and history. If the views are unrelated, use the sidebar instead. If each view is its own URL, this is navigation and belongs in the django variant.",
      "rules": [
        "role=\"tab\" only when the panel is in this document. If each tab is a URL, it is a nav of links with aria-current=\"page\", and nothing else. Putting role=\"tab\" on a link promises a screen reader that arrows will switch panels and Tab will jump into one, and then neither happens.",
        "A tab carries aria-selected, not aria-current. They look interchangeable and are not: aria-current marks where you are in a set of destinations, aria-selected marks which of several panels is showing.",
        "Exactly one tab in the tab order — the selected one gets tabindex 0, the rest get -1. Without this roving tabindex, Tab walks every tab in the row and the panel is seven presses away, which is the opposite of what the pattern exists to do.",
        "Bind the arrow keys. A tablist without them is a row of buttons that only works with a mouse, whatever the roles say.",
        "Activate automatically only when the panels are already in the page. A tab that fetches uses manual activation, or arrowing from the first tab to the seventh fires seven requests and the user reads whichever one lands last.",
        "Generate the ids with $id(). Static ids look fine until a second copy of the same tab set lands on one page, and then two tabs point aria-controls at one panel and nothing reports an error.",
        "Never wrap the row onto a second line. It scrolls sideways, with the next tab peeking, because a second line of tabs reads as a different control.",
        "Do not colour the inactive tabs. They are text-zinc-600 and nothing else; the active one is the only one carrying weight.",
        "Counts go in a pill on the tab, not in the label, and the pill is tabular-nums so the row does not jitter when a number changes.",
        "x-cloak on every panel that is not the one showing at first paint, or all of them are visible for the moment before Alpine boots."
      ],
      "anatomy": [
        {
          "part": "Tablist",
          "description": "The row itself, holding the arrow-key bindings and a label that says what the set is for."
        },
        {
          "part": "Tab",
          "description": "A button when the panel is in this document, a link when the tab is a URL. The two are not interchangeable."
        },
        {
          "part": "Active marker",
          "description": "A 2px zinc-900 underline plus the weight change, and nothing else. Inactive tabs are text-zinc-600."
        },
        {
          "part": "Count or dot",
          "description": "A tabular-nums pill for a number, a 6px dot for a state. Colour on the dot only, per the status rule."
        },
        {
          "part": "Panel",
          "description": "The region below, tied to its tab by id in both directions, and focusable so it can be reached when it holds no controls."
        },
        {
          "part": "Overflow",
          "description": "The scrolling strip a long row becomes on a narrow screen, with the active tab scrolled into view rather than left off the edge."
        }
      ],
      "behaviour": [
        "Only the active tab carries weight and colour. Tinting the inactive ones destroys the one distinction the control exists to make.",
        "Two to seven tabs. Past that the row stops being scannable, and views that numerous are usually unrelated, which makes them sidebar entries.",
        "Left and right arrows move between tabs, Home and End jump to the ends, and Tab leaves the row entirely and lands in the panel. That last one is the whole point of the pattern: seven tabs cost one Tab press, not seven.",
        "Activation is automatic when the panels are local, so an arrow both moves and switches. When a tab fetches its panel, the arrow moves focus only and Enter or Space commits, which a native button already does.",
        "The row scrolls rather than wraps, and moving by keyboard brings the tab into view. A focused tab off the edge of the strip is a focus ring nobody can see.",
        "Switching does not reload and does not lose the panel scroll position. Where the tab is part of the record's address it goes in the query string with replaceState, not pushState, so Back leaves the record instead of undoing a tab."
      ],
      "accessibility": [
        "role=\"tablist\" on the row, role=\"tab\" on each control, role=\"tabpanel\" on each region, and an aria-label on the tablist naming the set.",
        "Each tab carries aria-selected and aria-controls; each panel carries aria-labelledby pointing back. Both directions, or the panel is announced without its name.",
        "Roving tabindex: the selected tab is 0, every other tab is -1, so Tab enters the row once and leaves into the panel.",
        "Arrow keys move, Home and End jump, and every one of them calls preventDefault so the page does not scroll underneath.",
        "The panel takes tabindex=\"0\" so it is reachable when it contains nothing focusable, which is most panels made of text.",
        "Ids come from $id(), so two tab sets on one page cannot cross-wire their aria-controls."
      ],
      "related": [
        "page-header",
        "accordion",
        "sidebar-nav"
      ],
      "variants": [
        {
          "id": "underline",
          "name": "Underline",
          "html": "<!-- The default. Automatic activation, because all four panels are already in\n     the page and there is nothing to wait for.\n\n     tabEls() filters on [role=tab] rather than reading children: x-for leaves a\n     <template> in the DOM and it counts as an element child. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'lines',\n       items: [\n         { id: 'summary',  label: 'Summary' },\n         { id: 'lines',    label: 'Lines',    count: 14 },\n         { id: 'receipts', label: 'Receipts', count: 3 },\n         { id: 'history',  label: 'History' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.tab);\n         this.pick(this.items[(i + step + n) % n].id);\n       },\n       pick(id) {\n         this.tab = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       }\n     }\">\n  <div class=\"border-b border-zinc-200\">\n    <div x-ref=\"list\" role=\"tablist\" aria-label=\"Purchase order sections\"\n         @keydown.arrow-right.prevent=\"move(1)\"\n         @keydown.arrow-left.prevent=\"move(-1)\"\n         @keydown.home.prevent=\"pick(items[0].id)\"\n         @keydown.end.prevent=\"pick(items[items.length - 1].id)\"\n         class=\"-mb-px flex gap-6\">\n      <template x-for=\"t in items\" :key=\"t.id\">\n        <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n                :id=\"$id('tab', t.id)\" :aria-controls=\"$id('panel', t.id)\"\n                :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n                :tabindex=\"tab === t.id ? 0 : -1\"\n                @click=\"pick(t.id)\"\n                class=\"flex shrink-0 items-center gap-2 rounded-t border-b-2 pb-2.5 text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n                :class=\"tab === t.id ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\">\n          <span x-text=\"t.label\"></span>\n          <template x-if=\"t.count\">\n            <span class=\"rounded-full bg-zinc-200 px-1.5 py-0.5 text-[11px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\" x-text=\"t.count\"></span>\n          </template>\n        </button>\n      </template>\n    </div>\n  </div>\n\n  <div class=\"pt-4 text-[14px]/5\">\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'summary')\" :aria-labelledby=\"$id('tab', 'summary')\"\n         x-show=\"tab === 'summary'\" x-cloak\n         class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">PO-24-1187 raised for Gujarat Polymers Ltd on 4 August, payment 45 days from GRN.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'lines')\" :aria-labelledby=\"$id('tab', 'lines')\"\n         x-show=\"tab === 'lines'\"\n         class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p>14 lines, <span class=\"font-medium tabular-nums\">₹18,42,000</span> before tax.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'receipts')\" :aria-labelledby=\"$id('tab', 'receipts')\"\n         x-show=\"tab === 'receipts'\" x-cloak\n         class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">3 GRNs posted, 2 lines still short.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'history')\" :aria-labelledby=\"$id('tab', 'history')\"\n         x-show=\"tab === 'history'\" x-cloak\n         class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">Approved by R. Menon on 5 August, revised once.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "pill",
          "name": "Pill",
          "html": "<!-- Same widget, different marker: a white pill on a zinc-100 track instead of\n     an underline. Reach for it when the tabs filter a list rather than section a\n     record, and when the row has to sit beside other controls without a rule\n     running under it. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'open',\n       items: [\n         { id: 'open',     label: 'Open' },\n         { id: 'awaiting', label: 'Awaiting GRN' },\n         { id: 'closed',   label: 'Closed' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.tab);\n         this.pick(this.items[(i + step + n) % n].id);\n       },\n       pick(id) {\n         this.tab = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       }\n     }\">\n  <div x-ref=\"list\" role=\"tablist\" aria-label=\"Order status\"\n       @keydown.arrow-right.prevent=\"move(1)\"\n       @keydown.arrow-left.prevent=\"move(-1)\"\n       @keydown.home.prevent=\"pick(items[0].id)\"\n       @keydown.end.prevent=\"pick(items[items.length - 1].id)\"\n       class=\"inline-flex rounded-lg bg-zinc-100 p-1\">\n    <template x-for=\"t in items\" :key=\"t.id\">\n      <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n              :id=\"$id('tab', t.id)\" :aria-controls=\"$id('panel', t.id)\"\n              :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n              :tabindex=\"tab === t.id ? 0 : -1\"\n              @click=\"pick(t.id)\"\n              class=\"rounded-md px-3 py-1.5 text-[13px]/5 whitespace-nowrap focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"tab === t.id ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-600 hover:text-zinc-900'\"\n              x-text=\"t.label\"></button>\n    </template>\n  </div>\n\n  <div class=\"pt-4 text-[14px]/5\">\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'open')\" :aria-labelledby=\"$id('tab', 'open')\"\n         x-show=\"tab === 'open'\" class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p>48 orders open, <span class=\"tabular-nums\">₹4,12,60,000</span> committed.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'awaiting')\" :aria-labelledby=\"$id('tab', 'awaiting')\"\n         x-show=\"tab === 'awaiting'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">27 orders delivered but not yet receipted.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'closed')\" :aria-labelledby=\"$id('tab', 'closed')\"\n         x-show=\"tab === 'closed'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">73 orders closed this quarter.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "scrollable",
          "name": "Scrollable",
          "html": "<!-- Seven tabs on a phone. The strip scrolls and the next tab peeks, which is\n     what stops it reading as a row that broke.\n\n     focus() scrolls a hidden element into view on both axes, which on a long\n     page means the whole document jumps to the tab strip. So focus with\n     preventScroll and do the horizontal scroll by hand, on the strip only. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'grn',\n       items: [\n         { id: 'overview', label: 'Overview' },\n         { id: 'po',       label: 'Purchase orders' },\n         { id: 'req',      label: 'Requisitions' },\n         { id: 'grn',      label: 'Goods receipt' },\n         { id: 'inv',      label: 'Invoices' },\n         { id: 'vendors',  label: 'Vendors' },\n         { id: 'rc',       label: 'Rate contracts' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       init() { this.$nextTick(() => this.reveal(this.tab)); },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.tab);\n         this.pick(this.items[(i + step + n) % n].id);\n       },\n       pick(id) {\n         this.tab = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus({ preventScroll: true });\n           this.reveal(id);\n         });\n       },\n       /* scrollIntoView by hand, so only the strip moves and the neighbour\n          still peeks by 16px */\n       reveal(id) {\n         const s = this.$refs.strip, el = this.tabEls().find(e => e.dataset.tab === id);\n         if (!el) return;\n         const a = el.getBoundingClientRect(), b = s.getBoundingClientRect();\n         if (a.left < b.left + 16) s.scrollBy({ left: a.left - b.left - 16 });\n         else if (a.right > b.right - 16) s.scrollBy({ left: a.right - b.right + 16 });\n       }\n     }\">\n  <div x-ref=\"strip\" class=\"overflow-x-auto border-b border-zinc-200 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n    <div x-ref=\"list\" role=\"tablist\" aria-label=\"Procurement sections\"\n         @keydown.arrow-right.prevent=\"move(1)\"\n         @keydown.arrow-left.prevent=\"move(-1)\"\n         @keydown.home.prevent=\"pick(items[0].id)\"\n         @keydown.end.prevent=\"pick(items[items.length - 1].id)\"\n         class=\"-mb-px flex w-max gap-6\">\n      <template x-for=\"t in items\" :key=\"t.id\">\n        <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n                :id=\"$id('tab', t.id)\" :aria-controls=\"$id('panel', 'all')\"\n                :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n                :tabindex=\"tab === t.id ? 0 : -1\"\n                @click=\"pick(t.id)\"\n                class=\"shrink-0 whitespace-nowrap rounded-t border-b-2 pb-2.5 text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n                :class=\"tab === t.id ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\"\n                x-text=\"t.label\"></button>\n      </template>\n    </div>\n  </div>\n\n  <!-- one panel whose contents change, so aria-labelledby follows the selection -->\n  <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'all')\" :aria-labelledby=\"$id('tab', tab)\"\n       class=\"rounded-lg pt-4 text-[14px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n    <p class=\"text-zinc-600\">Showing\n      <span class=\"font-medium text-zinc-900\" x-text=\"items.find(t => t.id === tab).label\"></span>.\n      On a narrow screen, drag the strip or use the arrow keys.</p>\n  </div>\n</div>"
        },
        {
          "id": "vertical",
          "name": "Vertical",
          "html": "<!-- For a settings page, where the labels are phrases rather than nouns and the\n     list is long enough that a row would scroll. Up and down arrows instead of\n     left and right, and aria-orientation says so.\n\n     The marker is the same 2px zinc-900 rule the horizontal variants use, only\n     turned on its side. The zinc-100 fill is the second half of the statement\n     and never the whole of it: drop these tabs onto the zinc-100 page surface\n     and a fill-only marker measures 1.00 against its background, which is not\n     faint but invisible. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'approvals',\n       items: [\n         { id: 'general',   label: 'General' },\n         { id: 'approvals', label: 'Approval limits' },\n         { id: 'numbering', label: 'Document numbering' },\n         { id: 'tax',       label: 'Tax and HSN' },\n         { id: 'users',     label: 'Users and roles' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.tab);\n         this.pick(this.items[(i + step + n) % n].id);\n       },\n       pick(id) {\n         this.tab = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       }\n     }\"\n     class=\"grid gap-6 sm:grid-cols-[190px_minmax(0,1fr)]\">\n  <div x-ref=\"list\" role=\"tablist\" aria-orientation=\"vertical\" aria-label=\"Settings sections\"\n       @keydown.arrow-down.prevent=\"move(1)\"\n       @keydown.arrow-up.prevent=\"move(-1)\"\n       @keydown.home.prevent=\"pick(items[0].id)\"\n       @keydown.end.prevent=\"pick(items[items.length - 1].id)\"\n       class=\"flex flex-col gap-0.5\">\n    <template x-for=\"t in items\" :key=\"t.id\">\n      <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n              :id=\"$id('tab', t.id)\" :aria-controls=\"$id('panel', t.id)\"\n              :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n              :tabindex=\"tab === t.id ? 0 : -1\"\n              @click=\"pick(t.id)\"\n              class=\"rounded-r-lg border-l-2 px-3 py-2 text-left text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n              :class=\"tab === t.id ? 'border-zinc-900 bg-zinc-100 font-medium text-zinc-900' : 'border-transparent text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900'\"\n              x-text=\"t.label\"></button>\n    </template>\n  </div>\n\n  <div class=\"min-w-0 text-[14px]/5\">\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'general')\" :aria-labelledby=\"$id('tab', 'general')\"\n         x-show=\"tab === 'general'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <h3 class=\"text-[14px]/5 font-semibold\">General</h3>\n      <p class=\"mt-1.5 text-zinc-600\">Company name, registered address and the financial year start.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'approvals')\" :aria-labelledby=\"$id('tab', 'approvals')\"\n         x-show=\"tab === 'approvals'\" class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Approval limits</h3>\n      <p class=\"mt-1.5 text-zinc-600\">Orders above <span class=\"font-medium tabular-nums text-zinc-900\">₹5,00,000</span> need a second approval.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'numbering')\" :aria-labelledby=\"$id('tab', 'numbering')\"\n         x-show=\"tab === 'numbering'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Document numbering</h3>\n      <p class=\"mt-1.5 text-zinc-600\">PO-YY-nnnn, reset every financial year.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'tax')\" :aria-labelledby=\"$id('tab', 'tax')\"\n         x-show=\"tab === 'tax'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Tax and HSN</h3>\n      <p class=\"mt-1.5 text-zinc-600\">Default GST rate per HSN code, applied when a line has none of its own.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'users')\" :aria-labelledby=\"$id('tab', 'users')\"\n         x-show=\"tab === 'users'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <h3 class=\"text-[14px]/5 font-semibold\">Users and roles</h3>\n      <p class=\"mt-1.5 text-zinc-600\">14 users, 4 roles. Buyers cannot approve their own orders.</p>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "url",
          "name": "Linkable",
          "html": "<!-- The tab becomes part of the record's address, so a link to \"the receipts of\n     PO-24-1187\" exists and a reload lands where you left.\n\n     replaceState, not pushState. With pushState, Back walks the user through\n     every tab they touched before it finally leaves the record, which is not\n     what Back means to anyone. If a tab genuinely is a place you can go back\n     to, it is a page, and that is the django variant. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'lines',\n       items: [\n         { id: 'lines',    label: 'Lines' },\n         { id: 'receipts', label: 'Receipts' },\n         { id: 'history',  label: 'History' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       init() {\n         const q = new URLSearchParams(location.search).get('tab');\n         if (this.items.some(t => t.id === q)) this.tab = q;\n       },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.tab);\n         this.pick(this.items[(i + step + n) % n].id);\n       },\n       pick(id) {\n         this.tab = id;\n         const u = new URL(location.href);\n         u.searchParams.set('tab', id);\n         history.replaceState(null, '', u);\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       }\n     }\">\n  <div class=\"border-b border-zinc-200\">\n    <div x-ref=\"list\" role=\"tablist\" aria-label=\"Purchase order sections\"\n         @keydown.arrow-right.prevent=\"move(1)\"\n         @keydown.arrow-left.prevent=\"move(-1)\"\n         @keydown.home.prevent=\"pick(items[0].id)\"\n         @keydown.end.prevent=\"pick(items[items.length - 1].id)\"\n         class=\"-mb-px flex gap-6\">\n      <template x-for=\"t in items\" :key=\"t.id\">\n        <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n                :id=\"$id('tab', t.id)\" :aria-controls=\"$id('panel', t.id)\"\n                :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n                :tabindex=\"tab === t.id ? 0 : -1\"\n                @click=\"pick(t.id)\"\n                class=\"shrink-0 rounded-t border-b-2 pb-2.5 text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n                :class=\"tab === t.id ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\"\n                x-text=\"t.label\"></button>\n      </template>\n    </div>\n  </div>\n\n  <div class=\"pt-4 text-[14px]/5\">\n    <template x-for=\"t in items\" :key=\"t.id\">\n      <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', t.id)\" :aria-labelledby=\"$id('tab', t.id)\"\n           x-show=\"tab === t.id\" x-cloak\n           class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n        <p class=\"text-zinc-600\">\n          <span class=\"font-medium text-zinc-900\" x-text=\"t.label\"></span> of PO-24-1187.\n          The address now ends <code class=\"rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[12px]/4 text-zinc-600\" x-text=\"'?tab=' + t.id\"></code>.\n        </p>\n      </div>\n    </template>\n  </div>\n</div>"
        },
        {
          "id": "lazy",
          "name": "Server-loaded panels",
          "html": "<!-- Manual activation, because each tab is a request. With automatic\n     activation, arrowing from the first tab to the fourth fires four fetches\n     and the user reads whichever one happens to land last. So the arrows move\n     focus only, and Enter or Space commits — which a real <button> already does\n     for free, so there is nothing to bind.\n\n     cursor is where focus is, tab is what is showing. Only in automatic\n     activation are those the same thing, which is why the other variants can\n     get away with one variable.\n\n     The hx- attributes and the panel id are written out rather than generated\n     with $id(): htmx reads them when it processes the element, and that is not\n     guaranteed to be after Alpine has bound them. Two of these on one page need\n     their ids changed by hand. -->\n<div x-data=\"{\n       tab: 'summary',\n       cursor: 'summary',\n       items: [\n         { id: 'summary',  label: 'Summary' },\n         { id: 'lines',    label: 'Lines' },\n         { id: 'receipts', label: 'Receipts' },\n         { id: 'history',  label: 'History' }\n       ],\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       move(step) {\n         const n = this.items.length;\n         const i = this.items.findIndex(t => t.id === this.cursor);\n         this.focusTab(this.items[(i + step + n) % n].id);\n       },\n       focusTab(id) {\n         this.cursor = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       },\n       pick(id) { this.tab = id; this.cursor = id; }\n     }\">\n  <div class=\"border-b border-zinc-200\">\n    <div x-ref=\"list\" role=\"tablist\" aria-label=\"Purchase order sections\"\n         @keydown.arrow-right.prevent=\"move(1)\"\n         @keydown.arrow-left.prevent=\"move(-1)\"\n         @keydown.home.prevent=\"focusTab(items[0].id)\"\n         @keydown.end.prevent=\"focusTab(items[items.length - 1].id)\"\n         class=\"-mb-px flex gap-6\">\n      <template x-for=\"t in items\" :key=\"t.id\">\n        <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n                :id=\"'po-tab-' + t.id\" aria-controls=\"po-panel\"\n                :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n                :tabindex=\"cursor === t.id ? 0 : -1\"\n                @click=\"pick(t.id)\"\n                :hx-get=\"'/orders/1187/' + t.id + '/'\"\n                hx-target=\"#po-panel\" hx-swap=\"innerHTML\" hx-indicator=\"#po-panel\"\n                class=\"shrink-0 rounded-t border-b-2 pb-2.5 text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n                :class=\"tab === t.id ? 'border-zinc-900 font-semibold text-zinc-900' : 'border-transparent text-zinc-600 hover:text-zinc-900'\"\n                x-text=\"t.label\"></button>\n      </template>\n    </div>\n  </div>\n\n  <!-- hx-indicator puts .htmx-request on this panel for the length of the\n       request, which is enough to fade it without any custom CSS -->\n  <div id=\"po-panel\" role=\"tabpanel\" tabindex=\"0\" :aria-labelledby=\"'po-tab-' + tab\" aria-live=\"polite\"\n       class=\"rounded-lg pt-4 text-[14px]/5 transition-opacity [&.htmx-request]:opacity-40 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n    <p class=\"text-zinc-600\">Summary renders with the page. Every other tab is fetched from\n      <code class=\"rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[12px]/4\">/orders/1187/&lt;tab&gt;/</code>\n      and swapped in here.</p>\n  </div>\n</div>"
        },
        {
          "id": "states",
          "name": "Count, flag and unavailable",
          "html": "<!-- Three things a tab has to be able to say: how many, something needs you,\n     and not yet.\n\n     The red dot follows the status rule — colour lives in a 6px marker, never\n     in a field of colour behind the label. The unavailable tab keeps\n     aria-disabled so it is still announced and still explains itself, but the\n     arrows skip it, because arrowing onto something you cannot open is a dead\n     end with no way to know why. It also drops aria-controls: there is no panel\n     to point at, and a reference to an id that is not in the document is worse\n     than no reference at all. -->\n<div x-id=\"['tab', 'panel']\"\n     x-data=\"{\n       tab: 'lines',\n       items: [\n         { id: 'lines',    label: 'Lines',    count: 14 },\n         { id: 'receipts', label: 'Receipts', count: 3, flag: '2 lines short' },\n         { id: 'invoices', label: 'Invoices', off: 'No invoice until a GRN is posted' }\n       ],\n       open() { return this.items.filter(t => !t.off); },\n       tabEls() { return Array.from(this.$refs.list.querySelectorAll('[role=tab]')); },\n       move(step) {\n         const list = this.open(), n = list.length;\n         const i = list.findIndex(t => t.id === this.tab);\n         this.pick(list[(i + step + n) % n].id);\n       },\n       pick(id) {\n         if (this.items.find(t => t.id === id).off) return;\n         this.tab = id;\n         this.$nextTick(() => {\n           const el = this.tabEls().find(e => e.dataset.tab === id);\n           if (el) el.focus();\n         });\n       }\n     }\">\n  <div class=\"border-b border-zinc-200\">\n    <div x-ref=\"list\" role=\"tablist\" aria-label=\"Purchase order sections\"\n         @keydown.arrow-right.prevent=\"move(1)\"\n         @keydown.arrow-left.prevent=\"move(-1)\"\n         @keydown.home.prevent=\"pick(open()[0].id)\"\n         @keydown.end.prevent=\"pick(open()[open().length - 1].id)\"\n         class=\"-mb-px flex gap-6\">\n      <template x-for=\"t in items\" :key=\"t.id\">\n        <button type=\"button\" role=\"tab\" :data-tab=\"t.id\"\n                :id=\"$id('tab', t.id)\"\n                :aria-selected=\"tab === t.id ? 'true' : 'false'\"\n                :aria-disabled=\"t.off ? 'true' : null\"\n                :aria-controls=\"t.off ? null : $id('panel', t.id)\"\n                :tabindex=\"tab === t.id ? 0 : -1\"\n                @click=\"pick(t.id)\"\n                class=\"flex shrink-0 items-center gap-2 rounded-t border-b-2 pb-2.5 text-[13px]/5 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\"\n                :class=\"t.off ? 'cursor-not-allowed border-transparent text-zinc-400'\n                              : tab === t.id ? 'border-zinc-900 font-semibold text-zinc-900'\n                                             : 'border-transparent text-zinc-600 hover:text-zinc-900'\">\n          <span x-text=\"t.label\"></span>\n          <template x-if=\"t.count\">\n            <span class=\"rounded-full bg-zinc-200 px-1.5 py-0.5 text-[11px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\" x-text=\"t.count\"></span>\n          </template>\n          <template x-if=\"t.flag\">\n            <span class=\"flex items-center gap-1.5\">\n              <span class=\"size-1.5 rounded-full bg-red-600\" aria-hidden=\"true\"></span>\n              <span class=\"sr-only\" x-text=\"t.flag\"></span>\n            </span>\n          </template>\n          <template x-if=\"t.off\">\n            <i data-lucide=\"lock\" class=\"size-3.5 text-zinc-400\"></i>\n          </template>\n        </button>\n      </template>\n    </div>\n  </div>\n\n  <div class=\"pt-4 text-[14px]/5\">\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'lines')\" :aria-labelledby=\"$id('tab', 'lines')\"\n         x-show=\"tab === 'lines'\" class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p>14 lines, <span class=\"font-medium tabular-nums\">₹18,42,000</span> before tax.</p>\n    </div>\n    <div role=\"tabpanel\" tabindex=\"0\" :id=\"$id('panel', 'receipts')\" :aria-labelledby=\"$id('tab', 'receipts')\"\n         x-show=\"tab === 'receipts'\" x-cloak class=\"rounded-lg focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-zinc-700/15\">\n      <p class=\"text-zinc-600\">3 GRNs posted. Two lines are short against the ordered quantity.</p>\n    </div>\n  </div>\n\n  <p class=\"mt-3 flex items-center gap-1.5 text-[12px]/4 text-zinc-500\">\n    <i data-lucide=\"lock\" class=\"size-3.5 shrink-0\"></i>Invoices opens once a GRN is posted.\n  </p>\n</div>"
        },
        {
          "id": "django",
          "name": "Tabs that are pages",
          "html": "<!-- When each tab is its own URL and its own view, this is not a tablist at\n     all. It is a nav of links that happens to be drawn as tabs, and the\n     difference is not cosmetic: role=\"tab\" would promise a screen reader that\n     the arrows move between panels in this document and that Tab jumps into\n     one, and then neither is true, because every click is a page load.\n\n     So: <a> not <button>, aria-current=\"page\" not aria-selected, no roles, no\n     roving tabindex, no key bindings. The browser already knows how to move\n     between links.\n\n     Choose this over the Alpine variants when the panel is expensive, when the\n     tab has to be bookmarkable and back-navigable, or when the server already\n     has a view per section. Choose the Alpine ones when the panels are cheap\n     and the record is one thing.\n\n     # urls.py\n     path('orders/<int:pk>/<slug:section>/', OrderDetail.as_view(), name='order-detail')\n\n     # views.py — section comes straight out of the URL, so the template needs\n     # no if-chain to work out which tab is on\n     class OrderDetail(DetailView):\n         def get_context_data(self, **kw):\n             return super().get_context_data(**kw) | {'section': self.kwargs['section']} -->\n<nav aria-label=\"Purchase order sections\" class=\"border-b border-zinc-200\">\n  <div class=\"-mb-px flex gap-6 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n    {% for key, label in sections %}\n      <a href=\"{% url 'order-detail' order.pk key %}\"\n         {% if key == section %}aria-current=\"page\"{% endif %}\n         class=\"shrink-0 whitespace-nowrap rounded-t border-b-2 pb-2.5 text-[13px]/5 {% if key == section %}border-zinc-900 font-semibold text-zinc-900{% else %}border-transparent text-zinc-600 hover:text-zinc-900{% endif %}\">\n        {{ label }}\n      </a>\n    {% endfor %}\n  </div>\n</nav>\n\n<div class=\"pt-4 text-[14px]/5\">\n  {% block section %}{% endblock %}\n</div>"
        }
      ]
    },
    {
      "id": "breadcrumbs",
      "name": "Breadcrumbs",
      "category": "navigation",
      "description": "The path from the section root down to the record on screen. The last crumb says where you are and is not a link.",
      "when_to_use": "Any page two or more levels deep. One level deep does not need a trail — it needs a back link, which is the last variant here.",
      "rules": [
        "The separator is a slash, always. There is no chevron variant and no icon variant; a trail that reads \"Home / Procurement / PO-24-1187\" in one file and uses chevrons in another is two components pretending to be one.",
        "The last crumb is plain text with aria-current=\"page\". Making it a link to itself is a dead control, and users click it and get nothing.",
        "Never truncate the last crumb. It is the one that says where you are. Truncate the middle, or collapse it into an overflow menu.",
        "The trail is the information hierarchy, not the browsing history. It does not change based on how the user arrived, so two people on the same record see the same trail.",
        "Crumbs are not underlined, unlike every other link in the system. Five underlined links in a row is a fence, not a path; the muted colour and the hover carry it instead."
      ],
      "anatomy": [
        {
          "part": "Trail",
          "description": "A <nav aria-label=\"Breadcrumb\"> holding an ordered list, because the order is the information and a screen reader needs to be able to skip it."
        },
        {
          "part": "Crumb",
          "description": "A link to an ancestor page, text-zinc-600, darkening to zinc-900 on hover."
        },
        {
          "part": "Separator",
          "description": "A zinc-400 slash. Decorative and aria-hidden, so it is never read aloud."
        },
        {
          "part": "Current crumb",
          "description": "Plain text in zinc-900 font-medium, never a link, carrying aria-current=\"page\"."
        },
        {
          "part": "Overflow",
          "description": "A menu holding the collapsed middle of a long trail, behind an ellipsis button."
        },
        {
          "part": "Trailing controls",
          "description": "Anything that belongs to the record rather than the page — a status pill, a copy-id button. They sit after the last crumb, never inside it."
        }
      ],
      "behaviour": [
        "The last crumb is the current page and is not a link. A link to the page you are on is a dead control.",
        "The last crumb is never truncated or collapsed — it is the one that says where you are.",
        "A long trail collapses its middle into an overflow menu rather than wrapping onto two lines.",
        "Below sm the trail collapses to the parent and the current page. Four crumbs at 390px either wrap or push the page sideways, and both are worse than showing two.",
        "The trail reflects the hierarchy, not the history, so it does not change based on how the user arrived.",
        "Crumb labels match the titles of the pages they point at, so following one is not a surprise."
      ],
      "accessibility": [
        "A <nav> with aria-label=\"Breadcrumb\", so assistive technology can identify it and skip past it.",
        "An ordered list inside, because the sequence carries the meaning.",
        "Separators are aria-hidden, or a screen reader reads \"slash\" between every crumb.",
        "The current page carries aria-current=\"page\" — that, not the styling, is what announces it.",
        "The overflow button has an aria-label naming how many levels it hides, because an ellipsis says nothing on its own.",
        "The browser's own focus ring is left alone on crumb links; nothing here overrides outline."
      ],
      "related": [
        "page-header",
        "tabs",
        "sidebar-nav"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- Three levels, which is the common case. The separator is aria-hidden so a\n     screen reader reads \"Home, Procurement, Purchase orders\" rather than\n     spelling out a slash between each one. -->\n<nav aria-label=\"Breadcrumb\">\n  <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Procurement</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><span aria-current=\"page\" class=\"font-medium text-zinc-900\">Purchase orders</span></li>\n  </ol>\n</nav>"
        },
        {
          "id": "record",
          "name": "On a record",
          "html": "<!-- A record page. The status and the copy button belong to the record, not to\n     the trail, so they sit after the last crumb rather than inside it — putting\n     them inside makes the accessible name of the current page read\n     \"PO-24-1187 Awaiting GRN Copy order number\". -->\n<nav aria-label=\"Breadcrumb\">\n  <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Procurement</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Purchase orders</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li class=\"flex items-center gap-2\">\n      <span aria-current=\"page\" class=\"font-medium tabular-nums text-zinc-900\">PO-24-1187</span>\n      <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 font-medium text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n        <span class=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\"></span>Awaiting GRN\n      </span>\n      <button type=\"button\" aria-label=\"Copy order number\" class=\"text-zinc-600 hover:text-zinc-900\">\n        <i data-lucide=\"copy\" class=\"size-3.5\"></i>\n      </button>\n    </li>\n  </ol>\n</nav>"
        },
        {
          "id": "truncated",
          "name": "Long labels",
          "html": "<!-- Vendor and project names run long. The middle crumbs truncate; the last one\n     never does. min-w-0 on the <li> is what makes truncate work at all inside a\n     flex row — without it the item refuses to shrink below its content. -->\n<nav aria-label=\"Breadcrumb\">\n  <ol class=\"flex flex-nowrap items-center gap-1.5 text-[13px]/5\">\n    <li class=\"shrink-0\"><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n    <li aria-hidden=\"true\" class=\"shrink-0 text-zinc-500\">/</li>\n    <li class=\"min-w-0 max-w-[14ch] shrink\">\n      <a href=\"#\" title=\"Procurement &amp; stores\" class=\"block truncate text-zinc-600 hover:text-zinc-900\">Procurement &amp; stores</a>\n    </li>\n    <li aria-hidden=\"true\" class=\"shrink-0 text-zinc-500\">/</li>\n    <li class=\"min-w-0 max-w-[18ch] shrink\">\n      <a href=\"#\" title=\"Gujarat Polymers Ltd — annual rate contract\" class=\"block truncate text-zinc-600 hover:text-zinc-900\">Gujarat Polymers Ltd — annual rate contract</a>\n    </li>\n    <li aria-hidden=\"true\" class=\"shrink-0 text-zinc-500\">/</li>\n    <li class=\"shrink-0\"><span aria-current=\"page\" class=\"font-medium tabular-nums text-zinc-900\">PO-24-1187</span></li>\n  </ol>\n</nav>"
        },
        {
          "id": "overflow",
          "name": "Collapsed middle",
          "html": "<!-- Past four levels the middle collapses behind an ellipsis rather than\n     wrapping onto a second line. The button says how many levels it hides,\n     because an ellipsis on its own announces nothing.\n\n     @click.stop on the button and the panel keeps the document-level handler\n     from closing this menu the moment it opens. -->\n<nav aria-label=\"Breadcrumb\" x-data=\"{ open: false }\" @click.outside=\"open = false\" @keydown.escape.window=\"open = false\">\n  <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n    <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li class=\"relative\">\n      <button type=\"button\" @click.stop=\"open = !open\"\n              aria-label=\"Show 3 hidden levels\" :aria-expanded=\"open\" aria-haspopup=\"menu\"\n              class=\"rounded-md px-1.5 py-0.5 text-zinc-600 hover:bg-zinc-200 hover:text-zinc-900\">…</button>\n      <div x-show=\"open\" x-cloak @click.stop role=\"menu\"\n           class=\"absolute left-0 z-40 mt-1 w-60 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-lg\">\n        <a href=\"#\" role=\"menuitem\" class=\"block truncate px-3 py-2 text-[13px]/5 hover:bg-zinc-100\">Procurement</a>\n        <a href=\"#\" role=\"menuitem\" class=\"block truncate px-3 py-2 text-[13px]/5 hover:bg-zinc-100\">Purchase orders</a>\n        <a href=\"#\" role=\"menuitem\" class=\"block truncate px-3 py-2 text-[13px]/5 hover:bg-zinc-100\">Gujarat Polymers Ltd</a>\n      </div>\n    </li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><a href=\"#\" class=\"tabular-nums text-zinc-600 hover:text-zinc-900\">PO-24-1187</a></li>\n    <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n    <li><span aria-current=\"page\" class=\"font-medium text-zinc-900\">Line 4 — MS angle 50×50×6</span></li>\n  </ol>\n</nav>"
        },
        {
          "id": "responsive",
          "name": "On a phone",
          "html": "<!-- The same trail twice, and only one is ever visible. Below sm everything\n     between the root and the parent is dropped and the parent takes a back\n     chevron, because four crumbs at 390px either wrap onto two lines or push\n     the page sideways.\n\n     Both copies sit in the DOM, and that is safe here only because hidden and\n     sm:hidden compile to display:none, which takes the inactive one out of the\n     accessibility tree as well as off the screen. Hide one with opacity or\n     visibility instead and a screen reader reads the path twice. -->\n<div>\n  <!-- phone: parent and current only -->\n  <nav aria-label=\"Breadcrumb\" class=\"sm:hidden\">\n    <ol class=\"flex items-center gap-1.5 text-[13px]/5\">\n      <li class=\"flex items-center gap-1\">\n        <i data-lucide=\"chevron-left\" class=\"size-3.5 text-zinc-600\"></i>\n        <a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Purchase orders</a>\n      </li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li class=\"min-w-0\"><span aria-current=\"page\" class=\"block truncate font-medium tabular-nums text-zinc-900\">PO-24-1187</span></li>\n    </ol>\n  </nav>\n\n  <!-- sm and up: the full trail -->\n  <nav aria-label=\"Breadcrumb\" class=\"hidden sm:block\">\n    <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n      <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Procurement</a></li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Purchase orders</a></li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li><span aria-current=\"page\" class=\"font-medium tabular-nums text-zinc-900\">PO-24-1187</span></li>\n    </ol>\n  </nav>\n</div>"
        },
        {
          "id": "page-header",
          "name": "Above a page title",
          "html": "<!-- Where a trail actually lives. It sits above the title, not beside it, and\n     the title repeats the last crumb — that repetition is correct: the crumb is\n     navigation and the h1 is the page. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white px-5 py-4\">\n  <nav aria-label=\"Breadcrumb\">\n    <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n      <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Home</a></li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Procurement</a></li>\n      <li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>\n      <li><span aria-current=\"page\" class=\"font-medium tabular-nums text-zinc-900\">PO-24-1187</span></li>\n    </ol>\n  </nav>\n\n  <div class=\"mt-2 flex flex-wrap items-start justify-between gap-3\">\n    <div>\n      <h1 class=\"text-[20px]/7 font-semibold tracking-tight tabular-nums\">PO-24-1187</h1>\n      <p class=\"mt-0.5 text-[13px]/5 text-zinc-600\">Gujarat Polymers Ltd · raised 14 Aug 2026 · 6 lines</p>\n    </div>\n    <div class=\"flex shrink-0 items-center gap-2\">\n      <button type=\"button\" class=\"rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">Export</button>\n      <button type=\"button\" class=\"rounded-lg bg-zinc-700 px-3 py-1.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Approve</button>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "back",
          "name": "One level deep",
          "html": "<!-- Not a breadcrumb, and that is the point. One level deep there is nothing to\n     trace, so a trail of two is ceremony. A back link says the same thing in\n     less space and gives a bigger target on a phone. -->\n<a href=\"#\" class=\"inline-flex items-center gap-1.5 text-[13px]/5 text-zinc-600 hover:text-zinc-900\">\n  <i data-lucide=\"chevron-left\" class=\"size-4\"></i>Purchase orders\n</a>"
        },
        {
          "id": "django",
          "name": "Django template",
          "html": "<!-- The trail comes from the view as a list of (label, url) pairs, with the\n     last entry carrying no url. forloop.last is what decides which crumb is\n     plain text, so the template never has to be told twice where it is.\n\n     # views.py\n     context['crumbs'] = [\n         ('Home', reverse('home')),\n         ('Procurement', reverse('procurement:index')),\n         ('Purchase orders', reverse('po:list')),\n         (order.number, None),\n     ]\n\n     Build it in the view, not in the template. A trail assembled from\n     request.path is browsing history wearing a hierarchy's clothes. -->\n<nav aria-label=\"Breadcrumb\">\n  <ol class=\"flex flex-wrap items-center gap-1.5 text-[13px]/5\">\n    {% for label, url in crumbs %}\n      {% if not forloop.first %}<li aria-hidden=\"true\" class=\"text-zinc-500\">/</li>{% endif %}\n      <li>\n        {% if forloop.last %}\n          <span aria-current=\"page\" class=\"font-medium text-zinc-900\">{{ label }}</span>\n        {% else %}\n          <a href=\"{{ url }}\" class=\"text-zinc-600 hover:text-zinc-900\">{{ label }}</a>\n        {% endif %}\n      </li>\n    {% endfor %}\n  </ol>\n</nav>"
        }
      ]
    },
    {
      "id": "sidebar-nav",
      "name": "Sidebar nav",
      "category": "navigation",
      "description": "The vertical navigation list that lives inside an app shell. This is the list only — the shell, its header and the scroll container belong to the layout.",
      "when_to_use": "The primary navigation of a console with more than about five destinations. For two or three destinations use tabs in the topbar.",
      "rules": [
        "Exactly one item carries aria-current=\"page\". The bg-zinc-100 tint is the visual half of the same statement — never one without the other.",
        "Counts are right-aligned and tabular-nums so the column of numbers stays straight.",
        "In the rail, the tooltip is a sibling span positioned to the right. Do not put the hover binding on the <i data-lucide> — Lucide swaps that element for an <svg> and the binding is lost.",
        "The rail is 68px so a 40px target keeps 14px either side. Below that the icons start colliding with the edge."
      ],
      "anatomy": [
        {
          "part": "Group",
          "description": "An 11px uppercase label over a set of items. Present once the list passes about six entries."
        },
        {
          "part": "Item",
          "description": "Icon, label, and an optional right-aligned count."
        },
        {
          "part": "Active item",
          "description": "bg-zinc-100 plus aria-current=\"page\" — the visual and the semantic halves of one statement."
        },
        {
          "part": "Count",
          "description": "Right-aligned and tabular-nums, so the numbers form a straight column."
        },
        {
          "part": "Rail tooltip",
          "description": "A sibling span shown on hover when the sidebar is collapsed. Never bound on the <i data-lucide>, which Lucide replaces."
        }
      ],
      "behaviour": [
        "Exactly one item is current at a time, and it carries both the tint and aria-current — never one without the other.",
        "In the 68px rail, labels are hidden with lg:hidden rather than removed, so the DOM and the tab order do not change.",
        "The rail is 68px so a 40px target keeps 14px either side; below that the icons start colliding with the edge.",
        "Hovering a rail item reveals its name in a tooltip, because a column of unlabelled icons is unusable to a new user.",
        "Groups do not collapse. A navigation list that hides its own items adds a click to every journey."
      ],
      "accessibility": [
        "The list is a <nav> with an accessible name, so it is a landmark.",
        "The current item carries aria-current=\"page\", which is what a screen reader uses — the tint is for everyone else.",
        "Collapsed items keep an accessible name even with the label visually hidden.",
        "Counts are part of the item's accessible name — \"Approvals, 12\" rather than an unattached number.",
        "Group labels are real headings or list captions, so the structure is announced rather than only drawn."
      ],
      "related": [
        "app-shell",
        "topbar",
        "tabs"
      ],
      "variants": [
        {
          "id": "expanded",
          "name": "Expanded",
          "html": "<nav aria-label=\"Main\" class=\"w-60 shrink-0 rounded-xl border border-zinc-200 bg-white p-2\">\n  <p class=\"px-2 pt-1 pb-1.5 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\">Operations</p>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"layout-dashboard\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Overview</span>\n  </a>\n  <a href=\"#\" aria-current=\"page\" class=\"flex items-center gap-2.5 rounded-lg bg-zinc-100 px-2 py-2 text-[13px]/5 font-medium\">\n    <i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Purchase orders</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">148</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"clipboard-list\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Requisitions</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">62</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"package-check\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Goods receipt</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">27</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"receipt\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Invoices</span>\n    <span class=\"rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-1.5 py-0.5 text-[11px]/4 font-medium tabular-nums text-zinc-700\">9</span>\n  </a>\n\n  <p class=\"px-2 pt-4 pb-1.5 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\">Master data</p>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"building-2\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Vendors</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">187</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"boxes\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Materials</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"file-signature\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Rate contracts</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"chart-no-axes-column\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Analytics</span>\n  </a>\n\n  <p class=\"px-2 pt-4 pb-1.5 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\">Saved views</p>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <span class=\"size-1.5 shrink-0 rounded-full bg-red-600\"></span>\n    <span class=\"flex-1 truncate\">Overdue over 7 days</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">18</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <span class=\"size-1.5 shrink-0 rounded-full bg-amber-500\"></span>\n    <span class=\"flex-1 truncate\">Awaiting GRN</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">27</span>\n  </a>\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <span class=\"size-1.5 shrink-0 rounded-full bg-zinc-400\"></span>\n    <span class=\"flex-1 truncate\">My approvals</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">6</span>\n  </a>\n</nav>"
        },
        {
          "id": "rail",
          "name": "Rail",
          "html": "<nav aria-label=\"Main\" class=\"w-[68px] shrink-0 rounded-xl border border-zinc-200 bg-white py-2\">\n  <div class=\"group relative flex justify-center\">\n    <a href=\"#\" aria-label=\"Overview\" class=\"flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"layout-dashboard\" class=\"size-4 text-zinc-600\"></i>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Overview</span>\n  </div>\n\n  <div class=\"group relative mt-1 flex justify-center\">\n    <a href=\"#\" aria-current=\"page\" aria-label=\"Purchase orders\"\n       class=\"relative flex size-10 items-center justify-center rounded-lg bg-zinc-100\">\n      <i data-lucide=\"file-text\" class=\"size-4 text-zinc-900\"></i>\n      <span class=\"absolute -top-0.5 -right-0.5 rounded-full bg-zinc-700 px-1.5 text-[11px]/4 tabular-nums text-white\">148</span>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Purchase orders — 148</span>\n  </div>\n\n  <div class=\"group relative mt-1 flex justify-center\">\n    <a href=\"#\" aria-label=\"Requisitions\" class=\"flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"clipboard-list\" class=\"size-4 text-zinc-600\"></i>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Requisitions — 62</span>\n  </div>\n\n  <div class=\"group relative mt-1 flex justify-center\">\n    <a href=\"#\" aria-label=\"Goods receipt\" class=\"flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"package-check\" class=\"size-4 text-zinc-600\"></i>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Goods receipt — 27</span>\n  </div>\n\n  <div class=\"group relative mt-1 flex justify-center\">\n    <a href=\"#\" aria-label=\"Invoices\" class=\"relative flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"receipt\" class=\"size-4 text-zinc-600\"></i>\n      <span class=\"absolute top-1.5 right-1.5 size-1.5 rounded-full bg-red-600\"></span>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Invoices — 9 on hold</span>\n  </div>\n\n  <div class=\"my-2 border-t border-zinc-100\"></div>\n\n  <div class=\"group relative flex justify-center\">\n    <a href=\"#\" aria-label=\"Vendors\" class=\"flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"building-2\" class=\"size-4 text-zinc-600\"></i>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Vendors — 187</span>\n  </div>\n\n  <div class=\"group relative mt-1 flex justify-center\">\n    <a href=\"#\" aria-label=\"Analytics\" class=\"flex size-10 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <i data-lucide=\"chart-no-axes-column\" class=\"size-4 text-zinc-600\"></i>\n    </a>\n    <span class=\"pointer-events-none absolute top-1/2 left-full z-40 ml-1 hidden -translate-y-1/2 rounded-lg bg-zinc-900 px-2 py-1 text-[12px]/4 whitespace-nowrap text-white group-hover:block\">Analytics</span>\n  </div>\n</nav>"
        },
        {
          "id": "nested",
          "name": "With nested children",
          "html": "<nav aria-label=\"Main\" class=\"w-60 shrink-0 rounded-xl border border-zinc-200 bg-white p-2\"\n     x-data=\"{ open: 'orders' }\">\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"layout-dashboard\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Overview</span>\n  </a>\n\n  <button @click=\"open = open === 'orders' ? '' : 'orders'\" :aria-expanded=\"open === 'orders'\"\n          class=\"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"file-text\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Purchase orders</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">148</span>\n    <span class=\"flex transition-transform\" :class=\"open === 'orders' && 'rotate-180'\">\n      <i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-500\"></i>\n    </span>\n  </button>\n  <div x-show=\"open === 'orders'\" class=\"ml-[26px] border-l border-zinc-100 pl-2\">\n    <a href=\"#\" aria-current=\"page\" class=\"flex items-center gap-2 rounded-lg bg-zinc-100 px-2 py-1.5 text-[13px]/5 font-medium\">\n      <span class=\"flex-1\">Awaiting GRN</span>\n      <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">27</span>\n    </a>\n    <a href=\"#\" class=\"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">\n      <span class=\"flex-1\">Overdue over 7 days</span>\n      <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">18</span>\n    </a>\n    <a href=\"#\" class=\"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">\n      <span class=\"flex-1\">My approvals</span>\n      <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">6</span>\n    </a>\n    <a href=\"#\" class=\"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">\n      <span class=\"flex-1\">Closed</span>\n    </a>\n  </div>\n\n  <button @click=\"open = open === 'vendors' ? '' : 'vendors'\" :aria-expanded=\"open === 'vendors'\"\n          class=\"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"building-2\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Vendors</span>\n    <span class=\"text-[11px]/4 tabular-nums text-zinc-600\">187</span>\n    <span class=\"flex transition-transform\" :class=\"open === 'vendors' && 'rotate-180'\">\n      <i data-lucide=\"chevron-down\" class=\"size-3.5 text-zinc-500\"></i>\n    </span>\n  </button>\n  <div x-show=\"open === 'vendors'\" x-cloak class=\"ml-[26px] border-l border-zinc-100 pl-2\">\n    <a href=\"#\" class=\"block rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">Approved</a>\n    <a href=\"#\" class=\"block rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">Pending KYC</a>\n    <a href=\"#\" class=\"block rounded-lg px-2 py-1.5 text-[13px]/5 hover:bg-zinc-100\">Rate contracts</a>\n  </div>\n\n  <a href=\"#\" class=\"flex items-center gap-2.5 rounded-lg px-2 py-2 text-[13px]/5 hover:bg-zinc-100\">\n    <i data-lucide=\"boxes\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1\">Materials</span>\n  </a>\n</nav>"
        }
      ]
    },
    {
      "id": "topbar",
      "name": "Topbar",
      "category": "navigation",
      "description": "The application header above the page content: where you are, what you can search, and who you are signed in as.",
      "when_to_use": "Every screen in the console. The page title and the primary action for the record belong in the page header below it, not here.",
      "rules": [
        "The hamburger is lg:hidden and only ever opens the sidebar. It is not a second menu with its own items.",
        "Sticky needs an explicit background — sticky top-0 bg-white — or the content scrolls through it.",
        "A notification dot means unread, not a count. If the number matters, show the number.",
        "Below lg the search field collapses to an icon button. Do not shrink the input instead; it stops being usable around 200px."
      ],
      "anatomy": [
        {
          "part": "Menu button",
          "description": "lg:hidden. The only way to open the sidebar on a phone, so it is never hidden there."
        },
        {
          "part": "Search",
          "description": "A wide input above lg, collapsing to an icon button below it."
        },
        {
          "part": "Notifications",
          "description": "A bell with a dot for unread. A dot means unread; if the number matters, show the number."
        },
        {
          "part": "Account",
          "description": "The avatar and the menu behind it."
        },
        {
          "part": "Surface",
          "description": "sticky top-0 with an explicit bg-white, or content scrolls straight through it."
        }
      ],
      "behaviour": [
        "The hamburger only ever opens the sidebar. It is not a second menu with its own items.",
        "Sticky positioning needs an explicit background. Without one the topbar is transparent and content scrolls through it.",
        "Below lg the search collapses to an icon button rather than shrinking — an input stops being usable around 200px.",
        "The page title and the record's primary action belong in the page header below, not up here.",
        "The notification dot indicates unread state only. A count replaces it when the quantity is actionable."
      ],
      "accessibility": [
        "The topbar is a <header> landmark, distinct from the sidebar's <nav>.",
        "The menu button has aria-label and aria-expanded reflecting the sidebar state.",
        "Search is a real labelled input inside a form, so Enter submits it.",
        "The unread dot is backed by text in the button's accessible name — \"Notifications, unread\" — since a dot announces nothing.",
        "The account menu follows the dropdown pattern: aria-haspopup, arrow keys and Escape."
      ],
      "related": [
        "app-shell",
        "sidebar-nav",
        "dropdown"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<header class=\"sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-zinc-200 bg-white px-3 sm:px-4\">\n  <button class=\"flex size-9 shrink-0 items-center justify-center rounded-lg hover:bg-zinc-100 lg:hidden\" aria-label=\"Open navigation\">\n    <i data-lucide=\"menu\" class=\"size-4\"></i>\n  </button>\n\n  <nav aria-label=\"Breadcrumb\" class=\"min-w-0\">\n    <ol class=\"flex items-center gap-1.5 text-[13px]/5\">\n      <li class=\"hidden sm:block\"><a href=\"#\" class=\"text-zinc-600 hover:text-zinc-900\">Procurement</a></li>\n      <li aria-hidden=\"true\" class=\"hidden text-zinc-500 sm:block\">/</li>\n      <li><span aria-current=\"page\" class=\"truncate font-medium\">Purchase orders</span></li>\n    </ol>\n  </nav>\n\n  <div class=\"ml-auto flex items-center gap-2\">\n    <button class=\"flex size-9 items-center justify-center rounded-lg hover:bg-zinc-100 lg:hidden\" aria-label=\"Search\">\n      <i data-lucide=\"search\" class=\"size-4 text-zinc-600\"></i>\n    </button>\n    <div class=\"hidden w-64 items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15 lg:flex\">\n      <i data-lucide=\"search\" class=\"ml-3 size-4 shrink-0 text-zinc-600\"></i>\n      <label for=\"topbar-search\" class=\"sr-only\">Search orders, vendors and materials</label>\n      <input id=\"topbar-search\" placeholder=\"Search orders, vendors…\"\n             class=\"w-full bg-transparent px-2 py-1.5 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n      <kbd class=\"mr-2 rounded border border-zinc-200 px-1.5 py-0.5 text-[11px]/4 text-zinc-500\">⌘K</kbd>\n    </div>\n\n    <span class=\"hidden items-center gap-1.5 rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 px-2.5 py-1 text-[11px]/4 font-medium text-zinc-700 sm:flex\">\n      <span class=\"size-1.5 rounded-full bg-emerald-600\"></span>Synced 2 min ago\n    </span>\n\n    <button class=\"relative flex size-9 items-center justify-center rounded-lg hover:bg-zinc-100\" aria-label=\"Notifications, unread\">\n      <i data-lucide=\"bell\" class=\"size-4 text-zinc-600\"></i>\n      <span class=\"absolute top-1.5 right-1.5 size-2 rounded-full bg-red-600 ring-2 ring-white\"></span>\n    </button>\n\n    <button class=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-200 ring-1 ring-inset ring-zinc-300 text-[12px]/4 font-medium\" aria-label=\"Account — Rajesh Menon\">\n      RM\n    </button>\n  </div>\n</header>"
        },
        {
          "id": "compact",
          "name": "Compact",
          "html": "<header class=\"flex h-12 items-center gap-3 border-b border-zinc-200 bg-white px-3 sm:px-4\">\n  <h1 class=\"truncate text-[16px]/6 font-semibold\">Konspec Operations</h1>\n  <div class=\"ml-auto flex items-center gap-2\">\n    <button class=\"rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px]/5 font-medium hover:bg-zinc-100\">Export</button>\n    <button class=\"flex items-center gap-2 rounded-lg bg-zinc-700 px-3 py-1.5 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">\n      <i data-lucide=\"plus\" class=\"size-4\"></i><span class=\"hidden sm:inline\">New order</span>\n    </button>\n  </div>\n</header>"
        }
      ]
    },
    {
      "id": "accordion",
      "name": "Accordion",
      "category": "navigation",
      "description": "Sections of content that collapse to their heading. The heading is a button that toggles the panel below it.",
      "when_to_use": "Long forms broken into stages, or reference detail most people will not open. Never hide something the user has to act on.",
      "rules": [
        "x-collapse needs the Alpine collapse plugin loaded before Alpine core. Without it the directive is ignored and the panel never shows — drop x-collapse and the plain x-show still works, it just snaps open instead of animating.",
        "The rotate binding goes on a wrapping <span>, never on the <i data-lucide>. Lucide replaces that element with an <svg> and the binding dies with it.",
        "Rotation needs a block-level box in Tailwind v4 — the wrapping span is flex for that reason.",
        "Give the header a fixed height. If it grows when open, the second click lands somewhere else.",
        "Every panel needs an id, and its button needs aria-controls pointing at it. Without that pairing a screen reader announces a button that expands nothing.",
        "Never put padding on the element x-collapse animates. box-sizing is border-box, so height:0 cannot go below the padding — the panel bottoms out at the padding height and then x-show removes it in a single frame, which reads as a snap. Put the padding on an inner div."
      ],
      "anatomy": [
        {
          "part": "Root",
          "description": "Holds the open state. One x-data for the whole group when only one panel may be open, one per item when several may."
        },
        {
          "part": "Header",
          "description": "A real button at a fixed height, wrapped in a heading element so the group appears in the document outline."
        },
        {
          "part": "Indicator",
          "description": "A chevron in a flex span. The rotation class goes on the span, never on the icon."
        },
        {
          "part": "Panel",
          "description": "The collapsible region, carrying an id the header points at through aria-controls."
        },
        {
          "part": "Meta",
          "description": "Optional right-aligned summary — a count, an amount, a status — so the row is useful while closed."
        }
      ],
      "behaviour": [
        "Clicking the header toggles its own panel. Clicking an open header closes it.",
        "In single-open mode, opening one panel closes the others. Track the open item by id, not by index, so reordering does not move the open state.",
        "A panel that starts closed needs x-cloak. A panel that starts open must not have it, or it stays hidden until Alpine boots.",
        "The header height does not change between states, so a second click always lands on the same control."
      ],
      "accessibility": [
        "The header is a button, never a div with a click handler.",
        "aria-expanded reflects the open state and is bound, not hardcoded.",
        "aria-controls on the button matches the id on the panel.",
        "Wrap the header in h3, or whichever level fits the page outline, so screen readers can jump between sections.",
        "The chevron is decorative and carries no label — the heading text alone says what the section is."
      ],
      "related": [
        "collapsible",
        "tabs",
        "card"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<div class=\"rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: true }\">\n  <h3>\n    <button @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"acc-delivery\"\n            class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n      <span class=\"flex-1 text-[14px]/5 font-medium\">Delivery and freight</span>\n      <span class=\"text-[12px]/4 text-zinc-500\">3 of 3 filled</span>\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n  </h3>\n  <div id=\"acc-delivery\" x-show=\"open\" class=\"border-t border-zinc-100 px-4 py-3 text-[14px]/5\">\n    <dl class=\"grid gap-2 sm:grid-cols-2\">\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Deliver to</dt><dd>Site store — Vasai plant</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Promised date</dt><dd class=\"tabular-nums\">22 August 2026</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Freight</dt><dd class=\"tabular-nums\">₹14,500 — to pay</dd></div>\n    </dl>\n  </div>\n</div>"
        },
        {
          "id": "single",
          "name": "Single-open",
          "html": "<div class=\"divide-y divide-zinc-100 rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: 'terms' }\">\n  <div>\n    <h3>\n      <button @click=\"open = open === 'terms' ? '' : 'terms'\" :aria-expanded=\"open === 'terms'\" aria-controls=\"acc-terms\"\n              class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n        <span class=\"flex-1 text-[14px]/5 font-medium\">Payment terms</span>\n        <span class=\"flex transition-transform\" :class=\"open === 'terms' && 'rotate-180'\">\n          <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n      </button>\n    </h3>\n    <div id=\"acc-terms\" x-show=\"open === 'terms'\" x-collapse.duration.200ms>\n      <div class=\"px-4 pb-3 text-[14px]/5 text-zinc-600\">\n        45 days from GRN posting. 2% early-payment discount inside 10 days.\n      </div>\n    </div>\n  </div>\n  <div>\n    <h3>\n      <button @click=\"open = open === 'tax' ? '' : 'tax'\" :aria-expanded=\"open === 'tax'\" aria-controls=\"acc-tax\"\n              class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n        <span class=\"flex-1 text-[14px]/5 font-medium\">Tax and GST</span>\n        <span class=\"flex transition-transform\" :class=\"open === 'tax' && 'rotate-180'\">\n          <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n      </button>\n    </h3>\n    <div id=\"acc-tax\" x-show=\"open === 'tax'\" x-cloak x-collapse.duration.200ms>\n      <div class=\"px-4 pb-3 text-[14px]/5 text-zinc-600\">\n        IGST 18% on <span class=\"tabular-nums\">₹18,42,000</span>. Vendor GSTIN 24AABCG1429P1ZK verified 12 July.\n      </div>\n    </div>\n  </div>\n  <div>\n    <h3>\n      <button @click=\"open = open === 'qc' ? '' : 'qc'\" :aria-expanded=\"open === 'qc'\" aria-controls=\"acc-qc\"\n              class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n        <span class=\"flex-1 text-[14px]/5 font-medium\">Inspection</span>\n        <span class=\"flex transition-transform\" :class=\"open === 'qc' && 'rotate-180'\">\n          <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n      </button>\n    </h3>\n    <div id=\"acc-qc\" x-show=\"open === 'qc'\" x-cloak x-collapse.duration.200ms>\n      <div class=\"px-4 pb-3 text-[14px]/5 text-zinc-600\">\n        Mill test certificate required per heat number. Reject on hardness outside 190–240 BHN.\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "cards",
          "name": "Bordered card list",
          "html": "<div x-data=\"{ open: 'grn-3391' }\" class=\"space-y-2\">\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <h3>\n      <button @click=\"open = open === 'grn-3391' ? '' : 'grn-3391'\" :aria-expanded=\"open === 'grn-3391'\" aria-controls=\"acc-grn-3391\"\n              class=\"flex min-h-14 w-full items-center gap-3 px-4 py-3 text-left\">\n        <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\">\n          <i data-lucide=\"package-check\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"block truncate text-[14px]/5 font-medium\">GRN-3391 — Gujarat Polymers Ltd</span>\n          <span class=\"block text-[12px]/4 text-zinc-600\">Posted 11 August · 4 lines</span>\n        </span>\n        <span class=\"hidden text-[13px]/5 tabular-nums sm:block\">₹6,18,400</span>\n        <span class=\"flex transition-transform\" :class=\"open === 'grn-3391' && 'rotate-180'\">\n          <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n      </button>\n    </h3>\n    <div id=\"acc-grn-3391\" x-show=\"open === 'grn-3391'\" class=\"border-t border-zinc-100 px-4 py-3 text-[14px]/5 text-zinc-600\">\n      HDPE granules 8,400 kg received against 8,600 kg ordered. Short-closed by R. Menon.\n    </div>\n  </div>\n\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <h3>\n      <button @click=\"open = open === 'grn-3388' ? '' : 'grn-3388'\" :aria-expanded=\"open === 'grn-3388'\" aria-controls=\"acc-grn-3388\"\n              class=\"flex min-h-14 w-full items-center gap-3 px-4 py-3 text-left\">\n        <span class=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100\">\n          <i data-lucide=\"package-check\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n        <span class=\"min-w-0 flex-1\">\n          <span class=\"block truncate text-[14px]/5 font-medium\">GRN-3388 — Sharma Steel Traders</span>\n          <span class=\"block text-[12px]/4 text-zinc-600\">Posted 8 August · 2 lines</span>\n        </span>\n        <span class=\"hidden text-[13px]/5 tabular-nums sm:block\">₹2,74,900</span>\n        <span class=\"flex transition-transform\" :class=\"open === 'grn-3388' && 'rotate-180'\">\n          <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n        </span>\n      </button>\n    </h3>\n    <div id=\"acc-grn-3388\" x-show=\"open === 'grn-3388'\" x-cloak class=\"border-t border-zinc-100 px-4 py-3 text-[14px]/5 text-zinc-600\">\n      MS angle 50×50×6 — 12.4 t accepted, mill test certificates on file.\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "collapsible",
      "name": "Collapsible",
      "category": "navigation",
      "description": "One trigger and one panel. The standalone disclosure — show more, an advanced section at the foot of a form, a filter bar that folds away.",
      "when_to_use": "A single region worth hiding by default and cheap to open: extra fields under a summary, advanced options, a filter panel above a register, an audit trail nobody reads. The moment there are two or more of these stacked and their headings read as a set, it is an accordion — that component owns the group, the single-open rule and the heading outline, and a row of separate collapsibles pretending to be one is the thing it exists to stop. Never collapse something the user has to act on to finish the task in front of them.",
      "rules": [
        "x-collapse comes from the Alpine collapse plugin, and the plugin has to be on the page before Alpine core. Without it the directive is not an error — it is ignored, and the panel toggles with the plain x-show underneath it, which snaps instead of animating. The failure looks like a styling problem and it is a script tag.",
        "Never put padding or a border on the element x-collapse animates. box-sizing is border-box, so height:0 cannot go below padding-top + padding-bottom + the borders — the panel bottoms out at that height and x-show then removes it in one frame, which reads as a snap at the end of a smooth close. Padding, borders and background go on an inner div; the animated element carries nothing but the directive and its id.",
        "That inner div takes padding, not margin. A margin on the first or last child collapses straight through the animated wrapper, so the height the plugin measures is short by the margin and the panel clips its own content until the next toggle.",
        "A trigger inside a <form> needs type=\"button\". The default is submit, so the first click on Advanced options posts a half-filled order instead of opening anything, and the bug only shows up once the collapsible is dropped into a real form.",
        "The trigger goes above the panel and has a fixed height. Below the panel it is pushed down by the whole height of what just opened and the second click lands in the middle of the new content; growing when open it moves under a stationary cursor. h-9 or h-12 on the trigger and it stays where it was clicked.",
        "The rotation binds to a wrapping <span>, never to the <i data-lucide>. createIcons() replaces that element with an <svg> and every binding on it dies with it. The span is flex because a transform needs a block-level box in Tailwind v4.",
        "aria-expanded on the trigger, aria-controls carrying the panel id, and the panel actually carrying that id. Two out of three is the usual state of this component in the wild and it announces a button that expands nothing. When there is no panel in the DOM at all — a locked section — drop aria-controls rather than pointing it at an id that does not exist.",
        "x-cloak on a panel that starts closed, and never on one that starts open. Alpine boots after the HTML paints: without it a closed panel is on screen for the first frames, and with it an open panel stays hidden until the script lands.",
        "<details> and x-collapse do not combine. The browser owns the open attribute and drops the content to display:none the instant it changes, so there is no frame left to animate a height in and the two fight over the same element. Pick one: <details> for a panel that needs no script, Alpine for one that animates or is driven from elsewhere.",
        "A collapsed panel is not a permission boundary. x-show renders the markup and hides it, so a cost breakup behind a trigger is in the page source of every user who can load the page. Gate it in the view — do not render what this user may not read.",
        "A collapsed panel still submits. display:none inputs post exactly like visible ones, so anything typed into an advanced section and then folded away still reaches the server. That is usually what you want; it stops being what you want the moment the trigger is used to mean \"these settings do not apply\".",
        "The closed state has to say what is inside it. A trigger reading Filters over a register showing 218 of 1,438 rows is how someone reports a missing order that was never missing — put the count of active filters on the trigger, or keep the applied chips outside the panel where folding it cannot hide them.",
        "Find-in-page does not reach an x-show panel. Ctrl-F walks rendered text, and display:none is not rendered, so a term inside a closed Alpine panel simply does not exist as far as the browser is concerned. Chrome and Safari do open a closed <details> to reveal a match — which is the argument for the native variant whenever the panel holds reference text someone will search.",
        "x-show writes display inline, so a plain class cannot override it. A panel that has to be open at md and up needs the important modifier — md:block!, with the bang at the end in Tailwind v4 — and that instance drops x-collapse, because the plugin writes an inline height too and the panel would sit at 0 on desktop.",
        "One x-data for the pair. A trigger with its own x-data and a panel with another are two independent copies of open that never see each other; the state belongs on the nearest ancestor of both.",
        "Seed the trigger label rather than leaving the x-text span empty. x-text overwrites whatever is inside the element, so <span x-text=\"open ? 'Show less' : 'Show more'\">Show more</span> reads correctly on the first paint and the button is not a blank strip until Alpine boots."
      ],
      "anatomy": [
        {
          "part": "Root",
          "description": "One x-data holding open, on the nearest element that contains both the trigger and the panel."
        },
        {
          "part": "Trigger",
          "description": "A real button at a fixed height, above the panel, carrying type=\"button\", aria-expanded and aria-controls."
        },
        {
          "part": "Summary",
          "description": "What the closed row still says — a count, an amount, how many filters are on. Without it the trigger is a door with nothing written on it."
        },
        {
          "part": "Indicator",
          "description": "A chevron in a flex span, rotated by a class on the span. Optional when the trigger label already flips between Show and Hide — two indicators say the same thing twice."
        },
        {
          "part": "Panel",
          "description": "The element x-collapse animates. It carries the id, x-show, x-cloak and nothing else: no padding, no border, no background."
        },
        {
          "part": "Body",
          "description": "The inner div holding the padding, the divider and the content, so the panel can genuinely reach height 0."
        }
      ],
      "behaviour": [
        "The trigger toggles one panel and nothing else. There is no group, no single-open rule and no coordination with anything beside it — that is the accordion.",
        "The trigger does not move between states. Its height is fixed and it sits above the panel, so a second click lands on the same control the first one did.",
        "The panel animates its height over 200ms and finishes at height auto, so content that arrives afterwards — an htmx swap, a row added — reflows instead of being clipped.",
        "A panel that starts closed carries x-cloak; one that starts open must not. The default state is a decision about what this record is for, not a habit.",
        "Fields inside a collapsed panel are still in the form and still post. Closing the section hides it; it does not clear it.",
        "A locked section shows its trigger disabled with a reason beside it, and its content is not in the document at all.",
        "A section with nothing in it is not a collapsible. Render the one line that says so, rather than a control that opens onto nothing."
      ],
      "accessibility": [
        "The trigger is a button, never a div with a click handler, and inside a form it is type=\"button\".",
        "aria-expanded is bound to the state, not written once. aria-controls names the panel id, and the panel carries it.",
        "Panel ids are unique on the page. Rendered in a loop they take the record key — col-lines-1187 — or the second trigger points at the first panel and both rows open the same thing.",
        "An icon-only trigger is named for what it opens — \"GST breakup\" — not for what it does. Expand is what the aria-expanded state already says.",
        "The chevron is decorative and carries no label. The trigger text is the accessible name.",
        "<summary> is already exposed as a button with an expanded state. Adding role, aria-expanded or aria-controls to it overwrites something the browser keeps correct for free.",
        "A disabled trigger uses the disabled attribute, so it leaves the Tab order, and the reason it is locked is text beside it rather than a title attribute."
      ],
      "related": [
        "accordion",
        "card",
        "tabs"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Show more",
          "html": "<!-- The trigger sits above the panel. Below it, opening pushes the trigger down\n     by the whole height of what appeared and the second click lands in the\n     middle of the content; at a fixed h-9 above it, the control does not move.\n\n     The animated div carries no padding and no border — border-box means\n     height:0 cannot go below them, so the close would bottom out at the padding\n     and then vanish in one frame. The dl inside takes both. -->\n<div class=\"max-w-xl rounded-xl border border-zinc-200 bg-white p-4\" x-data=\"{ open: false }\">\n  <div class=\"flex items-baseline justify-between gap-3\">\n    <p class=\"min-w-0 truncate text-[14px]/5 font-medium tabular-nums\">PO-24-1187 — Gujarat Polymers Ltd</p>\n    <p class=\"shrink-0 text-[14px]/5 tabular-nums\">₹18,42,000</p>\n  </div>\n  <p class=\"mt-1 text-[13px]/5 tabular-nums text-zinc-600\">Released 04/08/2026 · 6 lines · Vasai plant</p>\n\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-po-1187\"\n          class=\"mt-3 inline-flex h-9 items-center gap-1.5 text-[13px]/5 font-medium text-zinc-900 underline underline-offset-2\">\n    <span x-text=\"open ? 'Show less' : 'Show 6 more fields'\">Show 6 more fields</span>\n    <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n      <i data-lucide=\"chevron-down\" class=\"size-4\"></i>\n    </span>\n  </button>\n\n  <div id=\"col-po-1187\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n    <dl class=\"grid gap-x-6 gap-y-2.5 border-t border-zinc-100 pt-3 text-[14px]/5 sm:grid-cols-2\">\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Deliver to</dt><dd>Site store — Vasai plant</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Promised date</dt><dd class=\"tabular-nums\">22/08/2026</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Payment terms</dt><dd class=\"tabular-nums\">45 days from GRN</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Freight</dt><dd class=\"tabular-nums\">₹14,500 — to pay</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Vendor GSTIN</dt><dd class=\"tabular-nums\">24AABCG1429P1ZK</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Buyer</dt><dd>R. Menon — Indirect materials</dd></div>\n    </dl>\n  </div>\n</div>"
        },
        {
          "id": "advanced",
          "name": "Advanced options",
          "html": "<!-- type=\"button\" is load-bearing. A button inside a form defaults to submit,\n     so without it the first click on Advanced options posts a half-filled order\n     rather than opening anything.\n\n     The count on the trigger is what the closed row says about itself. Four\n     fields nobody can see is four fields nobody knows are there, and the ones\n     that already differ from the default are exactly the ones somebody has to\n     be told about.\n\n     Everything inside still posts. display:none inputs submit like any other,\n     so folding this section away hides it — it does not clear it. -->\n<form class=\"max-w-xl\" x-data=\"{ open: false }\">\n  <div class=\"space-y-4\">\n    <div>\n      <label for=\"ao-vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor</label>\n      <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n        <input id=\"ao-vendor\" name=\"vendor\" value=\"Gujarat Polymers Ltd\"\n               class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n      </div>\n    </div>\n    <div>\n      <label for=\"ao-date\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Required by</label>\n      <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n        <input id=\"ao-date\" name=\"required_by\" type=\"date\" value=\"2026-08-22\"\n               class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 tabular-nums outline-none\">\n      </div>\n    </div>\n  </div>\n\n  <div class=\"mt-5 rounded-xl border border-zinc-200 bg-white\">\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-advanced\"\n            class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n      <i data-lucide=\"sliders-horizontal\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      <span class=\"flex-1 text-[14px]/5 font-medium\">Advanced options</span>\n      <span class=\"inline-flex items-center rounded-full bg-zinc-200 px-2 py-0.5 text-[11px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\">4 fields</span>\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n\n    <div id=\"col-advanced\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n      <div class=\"space-y-4 border-t border-zinc-100 px-4 py-4\">\n        <div>\n          <label for=\"ao-freight\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Freight terms</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <select id=\"ao-freight\" name=\"freight\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n              <option>To pay — vendor arranges</option>\n              <option selected>Paid — included in rate</option>\n              <option>Ex-works — we collect</option>\n            </select>\n          </div>\n        </div>\n        <div>\n          <label for=\"ao-tol\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Receipt tolerance</label>\n          <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <input id=\"ao-tol\" name=\"tolerance\" value=\"2\" inputmode=\"decimal\"\n                   class=\"w-full bg-transparent px-3 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n            <span class=\"pr-3 text-[14px]/5 text-zinc-600\">%</span>\n          </div>\n          <p class=\"mt-1 text-[12px]/4 text-zinc-500\">Over-receipt allowed against the ordered quantity.</p>\n        </div>\n        <label class=\"flex items-start gap-2.5 text-[14px]/5\">\n          <input type=\"checkbox\" name=\"inspection\" value=\"1\" checked class=\"mt-0.5 size-4 shrink-0 accent-zinc-700\">\n          <span>Hold for inspection before the GRN is posted</span>\n        </label>\n        <div>\n          <label for=\"ao-note\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Remarks printed on the order</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <textarea id=\"ao-note\" name=\"remarks\" rows=\"3\"\n                      class=\"w-full resize-y bg-transparent px-3 py-2 text-[14px]/5 outline-none placeholder:text-zinc-500\"\n                      placeholder=\"Mill test certificate per heat number.\">Mill test certificate per heat number.</textarea>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Release order</button>\n</form>"
        },
        {
          "id": "filters",
          "name": "Filter panel",
          "html": "<!-- Two things keep a folded filter panel honest. The count on the trigger, so\n     the closed row admits the register is filtered; and the applied chips\n     outside the panel, so folding it cannot hide what is applied. Without both,\n     someone reports a missing purchase order that was never missing — it was\n     three filters away.\n\n     The result count sits beside the trigger for the same reason: 218 of 1,438\n     is the sentence that stops the phone call. -->\n<div class=\"rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{\n       open: false,\n       active: [\n         { id: 'vendor', label: 'Vendor: Gujarat Polymers' },\n         { id: 'status', label: 'Status: Awaiting GRN' },\n         { id: 'value',  label: 'Value: above ₹5,00,000' }\n       ],\n       drop(id) { this.active = this.active.filter(f => f.id !== id); }\n     }\">\n  <div class=\"flex h-12 items-center gap-3 px-4\">\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-filters\"\n            class=\"-mx-2 flex h-9 items-center gap-2 rounded-lg px-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"sliders-horizontal\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      Filters\n      <span x-show=\"active.length\"\n            class=\"inline-flex items-center rounded-full bg-zinc-200 px-1.5 py-0.5 text-[11px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\"\n            x-text=\"active.length\">3</span>\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n    <p class=\"ml-auto shrink-0 text-[13px]/5 tabular-nums text-zinc-600\"\n       x-text=\"active.length ? '218 of 1,438 orders' : '1,438 orders'\">218 of 1,438 orders</p>\n  </div>\n\n  <!-- outside the panel on purpose: closing the filters must not hide them -->\n  <div x-show=\"active.length\" class=\"flex flex-wrap items-center gap-2 border-t border-zinc-100 px-4 py-2.5\">\n    <template x-for=\"f in active\" :key=\"f.id\">\n      <span class=\"inline-flex items-center gap-1.5 rounded-full bg-zinc-200 py-0.5 pl-2.5 pr-1 text-[12px]/4 text-zinc-700 ring-1 ring-inset ring-zinc-300\">\n        <span x-text=\"f.label\"></span>\n        <button type=\"button\" @click=\"drop(f.id)\" :aria-label=\"'Remove filter ' + f.label\"\n                class=\"flex size-4 items-center justify-center rounded-full hover:bg-zinc-300\">\n          <i data-lucide=\"x\" class=\"size-3\"></i>\n        </button>\n      </span>\n    </template>\n    <button type=\"button\" @click=\"active = []\" class=\"text-[12px]/4 font-medium text-zinc-900 underline underline-offset-2\">Clear all</button>\n  </div>\n\n  <div id=\"col-filters\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n    <div class=\"border-t border-zinc-100 px-4 py-4\">\n      <div class=\"grid gap-4 sm:grid-cols-3\">\n        <div>\n          <label for=\"fl-vendor\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Vendor</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <select id=\"fl-vendor\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n              <option>All vendors</option>\n              <option selected>Gujarat Polymers Ltd</option>\n              <option>Sharma Extrusions</option>\n              <option>Nashik Steel Traders</option>\n            </select>\n          </div>\n        </div>\n        <div>\n          <label for=\"fl-status\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Status</label>\n          <div class=\"rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <select id=\"fl-status\" class=\"w-full bg-transparent px-3 py-2 text-[14px]/5 outline-none\">\n              <option>Any status</option>\n              <option>Open</option>\n              <option selected>Awaiting GRN</option>\n              <option>Closed</option>\n            </select>\n          </div>\n        </div>\n        <div>\n          <label for=\"fl-value\" class=\"mb-1.5 block text-[13px]/5 font-medium\">Value above</label>\n          <div class=\"flex items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-700 focus-within:ring-3 focus-within:ring-zinc-700/15\">\n            <span class=\"pl-3 text-[14px]/5 text-zinc-600\">₹</span>\n            <input id=\"fl-value\" value=\"5,00,000\" inputmode=\"numeric\"\n                   class=\"w-full bg-transparent px-2 py-2 text-right text-[14px]/5 tabular-nums outline-none\">\n          </div>\n        </div>\n      </div>\n      <div class=\"mt-4 flex flex-wrap items-center gap-2\">\n        <button type=\"button\" class=\"inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Apply filters</button>\n        <button type=\"button\" @click=\"open = false\" class=\"inline-flex h-9 items-center rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">Cancel</button>\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "native",
          "name": "No JavaScript",
          "html": "<!-- <details> and <summary>, and nothing else. What this buys:\n\n     It works before Alpine boots and with scripting off, so no x-cloak and no\n     first-paint flash. The summary is already exposed as a button with an\n     expanded state, so there is nothing to wire and nothing to keep in sync —\n     adding role, aria-expanded or aria-controls here overwrites something the\n     browser gets right for free. And Ctrl-F reaches inside it: Chrome and\n     Safari open a closed <details> to reveal a find-in-page match, which an\n     x-show panel can never do, because display:none text is not text as far as\n     the browser is concerned.\n\n     What it costs: no height animation. <details> and x-collapse do not\n     combine — the browser owns the open attribute and drops the content to\n     display:none the instant it changes, leaving no frame to animate in, and\n     the two end up fighting over the same element. The open state also cannot\n     be driven from anywhere else on the page without script, and anything\n     interactive inside the summary toggles the panel when it is clicked.\n\n     So: reference text somebody may search — an audit trail, terms, a policy\n     note. Use the Alpine version when the panel holds form controls, when the\n     trigger lives somewhere else, or when the movement is worth the plugin.\n\n     list-none plus the webkit pseudo removes the disclosure triangle; display\n     flex on the summary already does it in Chrome, and Safari needs the\n     pseudo. group-open on the wrapping span is what rotates the chevron —\n     still a span, never the <i>, because Lucide replaces that element. -->\n<details class=\"group max-w-xl rounded-xl border border-zinc-200 bg-white\">\n  <summary class=\"flex h-12 list-none items-center gap-3 px-4 [&::-webkit-details-marker]:hidden\">\n    <i data-lucide=\"history\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n    <span class=\"flex-1 text-[14px]/5 font-medium\">Audit trail</span>\n    <span class=\"shrink-0 text-[12px]/4 tabular-nums text-zinc-500\">7 events</span>\n    <span class=\"flex shrink-0 transition-transform group-open:rotate-180\">\n      <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n    </span>\n  </summary>\n\n  <div class=\"border-t border-zinc-100 px-4 py-3\">\n    <ol class=\"space-y-3 text-[13px]/5\">\n      <li class=\"flex gap-3\">\n        <span class=\"w-20 shrink-0 tabular-nums text-zinc-500\">11/08/2026</span>\n        <span class=\"min-w-0\">GRN-3391 posted against 4 lines — <span class=\"text-zinc-600\">S. Kulkarni, stores</span></span>\n      </li>\n      <li class=\"flex gap-3\">\n        <span class=\"w-20 shrink-0 tabular-nums text-zinc-500\">09/08/2026</span>\n        <span class=\"min-w-0\">Delivery date moved to 22/08/2026 — <span class=\"text-zinc-600\">vendor email</span></span>\n      </li>\n      <li class=\"flex gap-3\">\n        <span class=\"w-20 shrink-0 tabular-nums text-zinc-500\">04/08/2026</span>\n        <span class=\"min-w-0\">Order released — <span class=\"text-zinc-600\">R. Menon</span></span>\n      </li>\n      <li class=\"flex gap-3\">\n        <span class=\"w-20 shrink-0 tabular-nums text-zinc-500\">02/08/2026</span>\n        <span class=\"min-w-0\">Approved at <span class=\"tabular-nums\">₹18,42,000</span> — <span class=\"text-zinc-600\">A. Deshmukh, plant head</span></span>\n      </li>\n      <li class=\"flex gap-3\">\n        <span class=\"w-20 shrink-0 tabular-nums text-zinc-500\">01/08/2026</span>\n        <span class=\"min-w-0\">Raised from requisition REQ-24-0884 — <span class=\"text-zinc-600\">R. Menon</span></span>\n      </li>\n    </ol>\n  </div>\n</details>"
        },
        {
          "id": "card",
          "name": "Inside a card",
          "html": "<!-- The header and the footer are outside the panel, so the number this card is\n     about is on screen in both states. Collapse the body and the row still says\n     which invoice it is and what it comes to — collapse the total with it and\n     the closed card is a label with no value.\n\n     This one starts open, so it must not carry x-cloak: x-cloak would hold it\n     hidden until Alpine boots and the card would assemble itself in front of\n     the user.\n\n     The trigger is icon-only, so its name says what it opens rather than what\n     it does — aria-expanded already announces expanded or collapsed, and a\n     button called \"Expand\" in a page with four of them names nothing. -->\n<div class=\"max-w-xl rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: true }\">\n  <div class=\"flex min-h-14 items-center gap-3 px-4 py-3\">\n    <div class=\"min-w-0 flex-1\">\n      <h3 class=\"truncate text-[16px]/6 font-semibold\">GST breakup</h3>\n      <p class=\"mt-0.5 text-[12px]/4 tabular-nums text-zinc-600\">Invoice INV-7741 · 12/08/2026 · Gujarat Polymers Ltd</p>\n    </div>\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-gst\"\n            aria-label=\"GST breakup\"\n            class=\"-mr-1 flex size-9 shrink-0 items-center justify-center rounded-lg hover:bg-zinc-100\">\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n  </div>\n\n  <div id=\"col-gst\" x-show=\"open\" x-collapse.duration.200ms>\n    <dl class=\"divide-y divide-zinc-100 border-t border-zinc-100 px-4 text-[14px]/5\">\n      <div class=\"flex items-center justify-between gap-3 py-2.5\">\n        <dt class=\"text-zinc-600\">Taxable value</dt><dd class=\"tabular-nums\">₹18,42,000.00</dd>\n      </div>\n      <div class=\"flex items-center justify-between gap-3 py-2.5\">\n        <dt class=\"text-zinc-600\">CGST @ 9%</dt><dd class=\"tabular-nums\">₹1,65,780.00</dd>\n      </div>\n      <div class=\"flex items-center justify-between gap-3 py-2.5\">\n        <dt class=\"text-zinc-600\">SGST @ 9%</dt><dd class=\"tabular-nums\">₹1,65,780.00</dd>\n      </div>\n      <div class=\"flex items-center justify-between gap-3 py-2.5\">\n        <dt class=\"text-zinc-600\">Round off</dt><dd class=\"tabular-nums\">₹0.40</dd>\n      </div>\n    </dl>\n  </div>\n\n  <div class=\"flex items-center justify-between gap-3 border-t border-zinc-200 px-4 py-3\">\n    <span class=\"text-[13px]/5 font-medium\">Invoice total</span>\n    <span class=\"text-[16px]/6 font-semibold tabular-nums\">₹21,73,560</span>\n  </div>\n</div>"
        },
        {
          "id": "controlled",
          "name": "Trigger outside the panel",
          "html": "<!-- One x-data, on the nearest element that contains both. A trigger with its\n     own x-data and a panel with another are two copies of open that never see\n     each other — the button toggles a variable nothing is watching, and the\n     panel never moves.\n\n     No chevron here. The label already flips between Show and Hide, and a\n     rotating arrow beside it is the same fact twice.\n\n     With the trigger away from the panel, aria-controls is the only thing tying\n     them together and it is doing real work. Keep the panel after the trigger\n     in document order so Tab reaches it next. -->\n<div x-data=\"{ open: false }\" class=\"space-y-4\">\n  <div class=\"flex flex-wrap items-start justify-between gap-3\">\n    <div class=\"min-w-0\">\n      <h2 class=\"text-[20px]/7 font-semibold tracking-tight\">GRN-3391</h2>\n      <p class=\"mt-1 text-[13px]/5 tabular-nums text-zinc-600\">Gujarat Polymers Ltd · posted 11/08/2026</p>\n    </div>\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-lines\"\n            class=\"inline-flex h-9 shrink-0 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-4 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"list\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      <span x-text=\"open ? 'Hide line detail' : 'Show line detail'\">Show line detail</span>\n    </button>\n  </div>\n\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <dl class=\"grid grid-cols-2 gap-x-6 gap-y-3 px-4 py-3 sm:grid-cols-4\">\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Lines</dt><dd class=\"text-[14px]/5 tabular-nums\">4</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Received</dt><dd class=\"text-[14px]/5 tabular-nums\">8,400 kg</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Short</dt><dd class=\"text-[14px]/5 tabular-nums\">200 kg</dd></div>\n      <div><dt class=\"text-[12px]/4 text-zinc-600\">Value</dt><dd class=\"text-[14px]/5 tabular-nums\">₹6,18,400</dd></div>\n    </dl>\n\n    <div id=\"col-lines\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n      <ul class=\"divide-y divide-zinc-100 border-t border-zinc-100\">\n        <li class=\"flex items-baseline gap-3 px-4 py-3\">\n          <span class=\"min-w-0 flex-1 truncate text-[13px]/5\">HDPE granules — grade M60075</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">8,400 kg</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹5,04,000</span>\n        </li>\n        <li class=\"flex items-baseline gap-3 px-4 py-3\">\n          <span class=\"min-w-0 flex-1 truncate text-[13px]/5\">Masterbatch — white 20%</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">300 kg</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹72,000</span>\n        </li>\n        <li class=\"flex items-baseline gap-3 px-4 py-3\">\n          <span class=\"min-w-0 flex-1 truncate text-[13px]/5\">Antioxidant additive</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">40 kg</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹28,400</span>\n        </li>\n        <li class=\"flex items-baseline gap-3 px-4 py-3\">\n          <span class=\"min-w-0 flex-1 truncate text-[13px]/5\">Packing — HDPE liner bags</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">168 nos</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹14,000</span>\n        </li>\n      </ul>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "states",
          "name": "Locked, empty and open on desktop",
          "html": "<div class=\"max-w-xl space-y-4\">\n  <!-- Locked. The trigger is disabled, so it leaves the Tab order, and the\n       reason sits beside it as text rather than in a title attribute nobody on\n       a touchscreen will ever see. No chevron — a chevron promises something\n       opens.\n\n       There is no panel in the document at all, so there is no aria-controls\n       either: pointing it at an id that does not exist is worse than omitting\n       it. The content is absent because a collapsed panel is not a permission\n       boundary — x-show renders the markup and hides it, and a cost breakup\n       behind a trigger is in the page source of everyone who can load the\n       page. This one is gated in the view. -->\n  <div class=\"rounded-xl border border-zinc-200 bg-white\">\n    <button type=\"button\" disabled aria-expanded=\"false\"\n            class=\"flex h-12 w-full items-center gap-3 px-4 text-left text-zinc-500\">\n      <i data-lucide=\"lock\" class=\"size-4 shrink-0\"></i>\n      <span class=\"flex-1 text-[14px]/5 font-medium\">Cost breakup</span>\n    </button>\n    <p class=\"border-t border-zinc-100 px-4 py-2.5 text-[12px]/4 text-zinc-500\">\n      Category managers and above. Raise a request with the buying desk.\n    </p>\n  </div>\n\n  <!-- Empty. A collapsible with nothing behind it is a control that opens onto\n       a blank panel, and the only way to find that out is to click it. Say the\n       answer on the closed row and drop the control. -->\n  <div class=\"rounded-xl border border-zinc-200 bg-white px-4 py-3\">\n    <p class=\"text-[14px]/5 font-medium\">Amendments</p>\n    <p class=\"mt-1 flex items-center gap-2 text-[13px]/5 text-zinc-600\">\n      <i data-lucide=\"minus\" class=\"size-4 shrink-0 text-zinc-500\"></i>\n      None since this order was released on 04/08/2026.\n    </p>\n  </div>\n\n  <!-- Collapsed on a phone, open at md and up, where there is room for it.\n       x-show writes display inline and a plain class cannot beat an inline\n       style, so the desktop override is the important modifier — md:block! in\n       Tailwind v4, where the bang goes on the end of the utility and not the\n       front.\n\n       That instance drops x-collapse. The plugin writes an inline height as\n       well, and md:block! would then show a panel sitting at height 0.\n\n       The trigger is md:hidden and a plain heading takes its place at md, so\n       desktop is not left with an unlabelled block of text. -->\n  <div class=\"rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: false }\">\n    <h3 class=\"hidden h-12 items-center px-4 text-[14px]/5 font-medium md:flex\">Payment and freight terms</h3>\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-terms\"\n            class=\"flex h-12 w-full items-center gap-3 px-4 text-left md:hidden\">\n      <span class=\"flex-1 text-[14px]/5 font-medium\">Payment and freight terms</span>\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n    <div id=\"col-terms\" x-show=\"open\" x-cloak class=\"md:block!\">\n      <div class=\"border-t border-zinc-100 px-4 py-3 text-[14px]/5 text-zinc-600 md:border-t-0 md:pt-0\">\n        45 days from GRN posting, 2% early-payment discount inside 10 days.\n        Freight paid and included in the rate. Debit note raised on any short\n        receipt beyond the 2% tolerance.\n      </div>\n    </div>\n  </div>\n</div>"
        },
        {
          "id": "django",
          "name": "Server-rendered",
          "html": "{# templates/orders/_collapsible.html\n\n   Three things the server decides that the browser cannot.\n\n   1. Whether the panel starts open. Rendered in the x-data, and the x-cloak\n      goes on only when it starts closed — a panel that starts open and carries\n      x-cloak stays hidden until Alpine boots, which is the exact flash x-cloak\n      exists to prevent.\n\n   2. Whether the panel exists at all. A collapsed panel is not a permission\n      boundary: x-show renders the markup and hides it, so a cost breakup behind\n      a trigger is in the page source of every user who can load the page. The\n      {% if perms %} is what actually withholds it.\n\n   3. The panel id. Rendered in a loop it takes the record key, or the second\n      trigger's aria-controls points at the first panel and both rows open the\n      same thing. #}\n\n<div class=\"rounded-xl border border-zinc-200 bg-white\"\n     x-data=\"{ open: {% if section_open %}true{% else %}false{% endif %} }\">\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\"\n          aria-controls=\"col-lines-{{ order.pk }}\"\n          class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n    <span class=\"flex-1 text-[14px]/5 font-medium\">Order lines</span>\n    <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">{{ order.lines.count }} lines · ₹{{ order.total|floatformat:0 }}</span>\n    <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n      <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n    </span>\n  </button>\n\n  <div id=\"col-lines-{{ order.pk }}\" x-show=\"open\"\n       {% if not section_open %}x-cloak{% endif %} x-collapse.duration.200ms>\n    <ul class=\"divide-y divide-zinc-100 border-t border-zinc-100\">\n      {% for line in order.lines.all %}\n        <li class=\"flex items-baseline gap-3 px-4 py-3\">\n          <span class=\"min-w-0 flex-1 truncate text-[13px]/5\">{{ line.item }}</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">{{ line.qty }} {{ line.uom }}</span>\n          <span class=\"shrink-0 text-[13px]/5 tabular-nums\">₹{{ line.amount|floatformat:0 }}</span>\n        </li>\n      {% endfor %}\n    </ul>\n  </div>\n</div>\n\n{# The panel a user may not read is not rendered, hidden or otherwise. #}\n{% if perms.purchasing.view_cost %}\n  <div class=\"mt-4 rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: false }\">\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\"\n            aria-controls=\"col-cost-{{ order.pk }}\"\n            class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n      <span class=\"flex-1 text-[14px]/5 font-medium\">Cost breakup</span>\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n    <div id=\"col-cost-{{ order.pk }}\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n      <div class=\"border-t border-zinc-100 px-4 py-3 text-[14px]/5\">\n        {% include 'orders/_cost_breakup.html' %}\n      </div>\n    </div>\n  </div>\n{% endif %}\n\n{# A filter panel has to survive the reload its own Apply button causes, so the\n   open state is a GET parameter and not Alpine state — Alpine is reconstructed\n   from scratch on every page load and remembers nothing.\n\n   views.py\n       context['filters_open'] = bool(request.GET.get('f') or form.has_changed())\n\n   The hidden f=1 keeps the panel open through the submit; the count on the\n   trigger is what the closed row says, and without it a filtered register reads\n   as the whole register. #}\n<form method=\"get\" class=\"mt-4 rounded-xl border border-zinc-200 bg-white\"\n      x-data=\"{ open: {% if filters_open %}true{% else %}false{% endif %} }\">\n  <input type=\"hidden\" name=\"f\" value=\"1\">\n  <div class=\"flex h-12 items-center gap-3 px-4\">\n    <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\" aria-controls=\"col-filters\"\n            class=\"-mx-2 flex h-9 items-center gap-2 rounded-lg px-2 text-[13px]/5 font-medium hover:bg-zinc-100\">\n      <i data-lucide=\"sliders-horizontal\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n      Filters\n      {% if active_filters %}\n        <span class=\"inline-flex items-center rounded-full bg-zinc-200 px-1.5 py-0.5 text-[11px]/4 tabular-nums text-zinc-700 ring-1 ring-inset ring-zinc-300\">{{ active_filters|length }}</span>\n      {% endif %}\n      <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n        <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n      </span>\n    </button>\n    <p class=\"ml-auto shrink-0 text-[13px]/5 tabular-nums text-zinc-600\">{{ page_obj.paginator.count }} of {{ total_orders }} orders</p>\n  </div>\n  <div id=\"col-filters\" x-show=\"open\" {% if not filters_open %}x-cloak{% endif %} x-collapse.duration.200ms>\n    <div class=\"border-t border-zinc-100 px-4 py-4\">\n      {{ form.as_div }}\n      <button type=\"submit\" class=\"mt-4 inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-700 px-4 text-[13px]/5 font-medium text-white hover:bg-zinc-800\">Apply filters</button>\n    </div>\n  </div>\n</form>\n\n{# A panel expensive enough to be worth not rendering until it is opened.\n   Alpine does not fetch — htmx does. intersect fires the first time the element\n   is genuinely on screen, and a display:none element never intersects, so the\n   request goes out on the first open and once only.\n\n   urls.py\n       path('orders/<int:pk>/audit/', OrderAudit.as_view(), name='order-audit')\n\n   Keep the placeholder roughly the height of what replaces it. x-collapse\n   finishes the panel at height auto, so a swap landing after the animation\n   reflows correctly, but one landing inside those 200ms leaves the panel\n   measuring the placeholder. #}\n<div class=\"mt-4 rounded-xl border border-zinc-200 bg-white\" x-data=\"{ open: false }\">\n  <button type=\"button\" @click=\"open = !open\" :aria-expanded=\"open\"\n          aria-controls=\"col-audit-{{ order.pk }}\"\n          class=\"flex h-12 w-full items-center gap-3 px-4 text-left\">\n    <span class=\"flex-1 text-[14px]/5 font-medium\">Audit trail</span>\n    <span class=\"text-[12px]/4 tabular-nums text-zinc-500\">{{ order.events.count }} events</span>\n    <span class=\"flex transition-transform\" :class=\"open && 'rotate-180'\">\n      <i data-lucide=\"chevron-down\" class=\"size-4 text-zinc-600\"></i>\n    </span>\n  </button>\n  <div id=\"col-audit-{{ order.pk }}\" x-show=\"open\" x-cloak x-collapse.duration.200ms>\n    <div class=\"border-t border-zinc-100 px-4 py-3\"\n         hx-get=\"{% url 'order-audit' order.pk %}\" hx-trigger=\"intersect once\" hx-swap=\"innerHTML\">\n      <p class=\"text-[13px]/5 text-zinc-500\">Loading the audit trail…</p>\n    </div>\n  </div>\n</div>"
        }
      ]
    },
    {
      "id": "command-palette",
      "name": "Command palette",
      "category": "navigation",
      "description": "A ⌘K overlay for jumping to a record or firing an action by typing. It is a shortcut over the navigation, never the only route to something.",
      "when_to_use": "Consoles a person lives in all day with many destinations and records. Skip it on a three-page app — nobody will learn the shortcut.",
      "rules": [
        "Everything reachable here must also be reachable by clicking. The palette is an accelerator, not a hiding place.",
        "Both ⌘K and Ctrl-K must work, and both need .prevent — the browser binds ⌘K to the address bar.",
        "x-cloak on the overlay, otherwise it paints over the page on first load.",
        "A palette is a combobox, not a menu, because it has a text box. A menu moves real focus between its items; a combobox keeps real focus in the input and points at the active row with aria-activedescendant. So the rows are not buttons and not links: nothing inside role=\"option\" may be focusable, and a row that is a tab stop puts the caret outside the search box the moment somebody presses Tab.",
        "The palette is a dialog as well as a combobox. It covers the page with a dimmed overlay, so it takes role=\"dialog\", aria-modal=\"true\" and a name, and it has to hold focus to earn them: aria-modal on a panel Tab can walk out of tells a screen reader the page behind is inert while the keyboard proves it is not. The two go in together or neither does.",
        "x-trap.noscroll on the overlay does the focusing, the returning and the scroll lock, so show() sets three fields and focuses nothing itself. The trap opens on [autofocus], which is why the query input carries one: without it the trap lands on the first tabbable node, which is the Esc button. Do not also focus by hand. x-show has not written display when $nextTick runs, so focus() on the input is a silent no-op, and a bare requestAnimationFrame beats Alpine's flush, so a hand-rolled call would be racing the trap for the same element and losing on its own terms.",
        "The row id is derived from the record — :id=\"'cp-' + o.id\" — never from the loop index, because filtering renumbers the rows and aria-activedescendant then names whichever record moved into that slot. Key the loop on the same field or Alpine reuses nodes and the id and the row drift apart. The prefix belongs to the palette, so a second one on the page needs a second prefix or both write the same ids.",
        "Closing returns focus to whatever opened the palette, which is not always the trigger — ⌘K fires from wherever the caret already was. x-trap captures document.activeElement when it activates and puts it back on close, so this needs no bookkeeping of its own. Without that, every dismissal drops a keyboard user at the top of the document.",
        "The letters on the action rows are the application's own shortcuts, shown so people learn them. The palette does not listen for them, and must not: inside a search box, N types an N."
      ],
      "anatomy": [
        {
          "part": "Overlay",
          "description": "A dimmed field with the panel near the top, not centred — the list grows downward. It carries x-trap.noscroll, so it is what holds focus and locks the page behind."
        },
        {
          "part": "Input",
          "description": "role=\"combobox\", focused on open and holding real focus the whole time the palette is up. The query is cleared on close so the next open starts fresh."
        },
        {
          "part": "Group",
          "description": "Results split by kind — actions, records — each under a small label. role=\"group\" named by that label, and a group with no matches leaves with its heading."
        },
        {
          "part": "Result",
          "description": "One row, role=\"option\" and not a tab stop, with the active one tinted. The keyboard drives which is active."
        },
        {
          "part": "Empty state",
          "description": "What the list shows at zero matches — the query quoted back, not an empty panel."
        },
        {
          "part": "Live region",
          "description": "A sr-only role=\"status\" inside the dialog, carrying the number of matches. It sits inside because aria-modal hides everything outside the dialog from a screen reader."
        },
        {
          "part": "Footer",
          "description": "The key legend. Nobody learns arrow keys and Enter from nothing."
        }
      ],
      "behaviour": [
        "Everything reachable here is also reachable by clicking. The palette is an accelerator, never a hiding place.",
        "Both Cmd-K and Ctrl-K open it, and both need .prevent — the browser binds Cmd-K to the address bar.",
        "Opening moves focus into the query input, and Tab stays inside the panel, so nothing behind the overlay is reachable until it closes.",
        "Typing filters both groups at once, and every keystroke puts the highlight back on the first match, so Enter always takes the row at the top of the list.",
        "Arrow down and up move the active row over the flattened list in the order the rows are drawn, not group by group, and clamp at both ends rather than wrapping. Enter takes the active row, Escape closes.",
        "The highlight follows the mouse as well as the keyboard, so the row under the pointer and the row Enter would take are never two different rows.",
        "A group whose rows are all filtered out goes with its heading, and when nothing matches at all the list is replaced by an empty state naming the query.",
        "Closing clears the query, so reopening does not present the previous search as if it were current, and puts focus back on whatever opened the palette.",
        "On a three-page application, skip it entirely — nobody will learn the shortcut."
      ],
      "accessibility": [
        "The input is role=\"combobox\" with aria-autocomplete=\"list\", aria-expanded, aria-controls naming the list and aria-activedescendant naming the active row.",
        "The list is role=\"listbox\" with an accessible name and every row is role=\"option\". No row carries aria-selected: a palette commits nothing — a row is fired and the palette closes — so the only state a row has is being the active one, and aria-activedescendant is what carries that.",
        "Real focus never leaves the input. The highlight moves through aria-activedescendant, which is why every row needs a stable id derived from the record and why no row is a tab stop.",
        "Each group is role=\"group\" named by its heading, and the visible heading is aria-hidden — the group is already named, and a bare paragraph is not a permitted child of a listbox.",
        "The number of matches is announced from a role=\"status\" inside the dialog. Outside it the announcement would never arrive, because aria-modal=\"true\" takes everything outside the dialog out of the accessibility tree.",
        "The overlay is role=\"dialog\" with aria-modal=\"true\", named \"Command palette\", so the rest of the page is out of the accessibility tree while it is open.",
        "Focus is trapped in the panel and cannot reach the page behind. The trap opens on the query input because it carries autofocus, rather than on the Esc button, which is the first tabbable node.",
        "Escape closes and returns focus to whatever opened the palette, captured by x-trap at the moment it activated."
      ],
      "related": [
        "combobox",
        "sidebar-nav",
        "dropdown"
      ],
      "variants": [
        {
          "id": "default",
          "name": "Default",
          "html": "<!-- A palette has a text box, so it is a combobox and not a menu: real focus\n     stays in the input and the highlight is carried by aria-activedescendant.\n     That is why the rows are divs with role=\"option\" rather than buttons —\n     nothing inside an option may be focusable.\n\n     Row ids come from the record, never from the loop index. Filtering\n     renumbers the rows, and an index-derived id leaves aria-activedescendant\n     naming whichever record moved into that slot.\n\n     x-trap does the focusing and the returning, which is why show() sets\n     three fields and nothing else. It opens focus on [autofocus], so the\n     caret lands in the query rather than on the Esc button, and it restores\n     document.activeElement on close, which matters because ⌘K fires from\n     wherever the caret already was and the trigger is not always what opened\n     the palette. Focusing by hand here would race the trap: x-show has not\n     written display when $nextTick runs, so focus() on the input is a silent\n     no-op.\n\n     The letters on the action rows are the application's own shortcuts, shown\n     so people learn them. The palette does not listen for them: inside a\n     search box, N has to type an N. -->\n<div x-data=\"{\n       open: false, q: '', ai: 0,\n       groups: [\n         { name: 'Actions', items: [\n           { id: 'new-po',    label: 'New purchase order', icon: 'plus',          key: 'N' },\n           { id: 'post-grn',  label: 'Post goods receipt', icon: 'package-check', key: 'G' },\n           { id: 'approvals', label: 'Go to my approvals', icon: 'check-check',   key: 'A' }\n         ] },\n         { name: 'Recent records', items: [\n           { id: 'po-1187', label: 'PO-24-1187 — Gujarat Polymers Ltd', sub: 'HDPE granules · 14 lines', icon: 'file-text', amount: '₹18,42,000' },\n           { id: 'po-1179', label: 'PO-24-1179 — Sharma Steel Traders', sub: 'MS angle 50×50×6', flag: 'overdue 9 days', icon: 'file-text', amount: '₹2,74,900' },\n           { id: 'ven-deccan', label: 'Deccan Fasteners Pvt Ltd', sub: 'Vendor · rate contract to 31 March 2027', icon: 'building-2' }\n         ] }\n       ],\n       match(g) {\n         const s = this.q.trim().toLowerCase();\n         if (!s) return g.items;\n         return g.items.filter(o => (o.label + ' ' + (o.sub || '') + ' ' + (o.flag || '') + ' ' + g.name).toLowerCase().includes(s));\n       },\n       get list() { return this.groups.flatMap(g => this.match(g)); },\n       rowId(o) { return 'cp-' + o.id; },\n       get activeId() { return this.open && this.list[this.ai] ? this.rowId(this.list[this.ai]) : null; },\n       scroll() { this.$nextTick(() => { const el = document.getElementById(this.activeId); if (el) el.scrollIntoView({ block: 'nearest' }); }); },\n       show() { this.open = true; this.q = ''; this.ai = 0; },\n       hide() { this.open = false; this.q = ''; this.ai = 0; },\n       move(n) {\n         if (!this.list.length) return;\n         this.ai = Math.min(this.list.length - 1, Math.max(0, this.ai + n));\n         this.scroll();\n       },\n       pick(o) { this.hide(); },\n       commit() { const o = this.list[this.ai]; if (o) this.pick(o); }\n     }\"\n     @keydown.window.meta.k.prevent=\"show()\"\n     @keydown.window.ctrl.k.prevent=\"show()\"\n     @keydown.escape.window=\"hide()\">\n\n  <button type=\"button\" @click=\"show()\"\n          class=\"flex w-full max-w-sm items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[13px]/5 text-zinc-500 hover:bg-zinc-100\">\n    <i data-lucide=\"search\" class=\"size-4 text-zinc-600\"></i>\n    <span class=\"flex-1 text-left\">Search Konspec Operations</span>\n    <kbd class=\"rounded border border-zinc-200 px-1.5 py-0.5 text-[11px]/4\">⌘K</kbd>\n  </button>\n\n  <div x-show=\"open\" x-cloak x-trap.noscroll=\"open\"\n       class=\"fixed inset-0 z-50 flex items-start justify-center bg-zinc-900/30 px-3 pt-16 sm:pt-24\">\n    <div role=\"dialog\" aria-modal=\"true\" aria-label=\"Command palette\" @click.outside=\"hide()\"\n         class=\"w-full max-w-xl overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg\">\n\n      <div class=\"flex items-center gap-2 border-b border-zinc-100 px-3\">\n        <i data-lucide=\"search\" class=\"size-4 shrink-0 text-zinc-600\"></i>\n        <label for=\"cp-q\" class=\"sr-only\">Search orders, vendors and actions</label>\n        <input id=\"cp-q\" x-model=\"q\" type=\"text\" role=\"combobox\" autocomplete=\"off\" autofocus\n               aria-autocomplete=\"list\" aria-controls=\"cp-list\"\n               :aria-expanded=\"open\" :aria-activedescendant=\"activeId\"\n               placeholder=\"Search orders, vendors, actions…\"\n               @input=\"ai = 0\"\n               @keydown.arrow-down.prevent=\"move(1)\"\n               @keydown.arrow-up.prevent=\"move(-1)\"\n               @keydown.enter.prevent=\"commit()\"\n               class=\"w-full min-w-0 bg-transparent py-3 text-[14px]/5 outline-none placeholder:text-zinc-500\">\n        <button type=\"button\" @click=\"hide()\" class=\"rounded border border-zinc-200 px-1.5 py-0.5 text-[11px]/4 text-zinc-500\">Esc</button>\n      </div>\n\n      <div id=\"cp-list\" role=\"listbox\" aria-label=\"Results\" class=\"max-h-80 overflow-y-auto pb-1\">\n        <template x-for=\"g in groups\" :key=\"g.name\">\n          <div role=\"group\" :aria-label=\"g.name\" x-show=\"match(g).length\">\n            <!-- a sticky heading with no background of its own lets the rows\n                 scroll through the text and neither is readable -->\n            <p aria-hidden=\"true\"\n               class=\"sticky top-0 z-10 border-b border-zinc-100 bg-white px-3 py-1.5 text-[11px]/4 font-medium tracking-wider text-zinc-500 uppercase\"\n               x-text=\"g.name\"></p>\n            <template x-for=\"o in match(g)\" :key=\"o.id\">\n              <div :id=\"rowId(o)\" role=\"option\"\n                   @mousedown.prevent @click=\"pick(o)\" @mousemove=\"ai = list.findIndex(x => x.id === o.id)\"\n                   :class=\"list[ai] && list[ai].id === o.id ? 'bg-zinc-100' : ''\"\n                   class=\"flex items-center gap-2.5 px-3 py-2 text-[13px]/5\">\n                <span class=\"flex size-4 shrink-0 items-center justify-center text-zinc-600\">\n                  <i :data-lucide=\"o.icon\" class=\"size-4\"></i>\n                </span>\n                <span class=\"min-w-0 flex-1\">\n                  <span class=\"block truncate\" x-text=\"o.label\"></span>\n                  <span x-show=\"o.sub\" class=\"block truncate text-[12px]/4 text-zinc-500\"><span x-text=\"o.sub\"></span><span\n                        x-show=\"o.flag\" class=\"font-medium text-red-600\"> · <span x-text=\"o.flag\"></span></span></span>\n                </span>\n                <span x-show=\"o.amount\" class=\"hidden shrink-0 text-[12px]/4 tabular-nums text-zinc-600 sm:block\" x-text=\"o.amount\"></span>\n                <kbd x-show=\"o.key\" class=\"shrink-0 rounded border border-zinc-200 px-1.5 py-0.5 text-[11px]/4 text-zinc-500\" x-text=\"o.key\"></kbd>\n              </div>\n            </template>\n          </div>\n        </template>\n      </div>\n\n      <!-- an empty panel reads as a component that broke; this reads as a\n           search that found nothing -->\n      <div x-show=\"!list.length\" x-cloak class=\"px-4 py-6 text-center\">\n        <p class=\"text-[13px]/5 font-medium\">Nothing matches “<span x-text=\"q\"></span>”</p>\n        <p class=\"mt-1 text-[12px]/4 tabular-nums text-zinc-500\">Try an order number — PO-24-1187 — or a vendor name.</p>\n      </div>\n\n      <div class=\"flex items-center gap-3 border-t border-zinc-100 px-3 py-2 text-[11px]/4 text-zinc-500\">\n        <span><kbd class=\"rounded border border-zinc-200 px-1 py-0.5\">↑↓</kbd> move</span>\n        <span><kbd class=\"rounded border border-zinc-200 px-1 py-0.5\">↵</kbd> open</span>\n        <span class=\"ml-auto\">Konspec Operations</span>\n      </div>\n\n      <!-- inside the dialog, because aria-modal hides everything outside it\n           from a screen reader, and a live region it cannot see announces\n           nothing at all. -->\n      <p role=\"status\" class=\"sr-only\"\n         x-text=\"open ? (list.length === 1 ? '1 result' : list.length + ' results') : ''\"></p>\n    </div>\n  </div>\n</div>"
        }
      ]
    }
  ]
}
