block_pagination() and update_block_pagination() are new. The standalone paging input reports an integer active page, renders previous/next bounds and ellipsis truncation, and supports server updates for page count, selection, and disabled state (issue #94).
New block_accordion() / block_accordion_item() / update_block_accordion(): vertically stacked collapsible content sections (shadcn accordion) for FAQs, grouped settings, and filters. Supports type = "single" (one open at a time, optionally collapsible to none) and type = "multiple" (independent), an initial open set, per-item icon and disabled, and animated panel height. Panel bodies are arbitrary live Shiny/htmltools content, so reactive outputs inside a closed section keep working. ... supports rlang's !!! splice, so a programmatically built list of items can be passed directly (block_accordion(!!!items)). When id is set, input$<id> reports the open value(s) - a string/NULL (single) or a character vector (multiple) - and update_block_accordion() opens/closes items from the server. Triggers carry aria-expanded/aria-controls, closed panels are inert, and arrow/home/end move focus between headers. The lower-level block_collapsible() is deferred (#91).
New block_dropdown_menu() / update_block_dropdown_menu(): a portal-rendered action menu (shadcn dropdown-menu) built from dropdown_menu_item(), dropdown_menu_label(), and dropdown_menu_separator() parts. The trigger accepts a string label or an arbitrary tag (icon button, avatar); items support leading icons, right-aligned shortcut hints, disabled, and a destructive variant. Choosing an item reports its value to input$<id> as an event (fires again on a repeat choice, like an action button that carries a value). Full keyboard support — arrow/home/end navigation, enter/space to activate, escape/tab to close, and typeahead — with role="menu" semantics and focus return to the trigger. Checkbox/radio items and sub-menus are deferred to follow-up work (#86).
New block_combobox() / update_block_combobox(): a searchable select (shadcn Command + Popover) with type-to-filter over choices, keyboard navigation, and a no-results empty state. Supports single and multiple modes (chips with an optional max_items cap), search_placeholder/empty_message text, and the same hidden-native value bridge and updater contract as block_select(). Closes the biggest catalog gap for selectizeInput() migration (#85).
New block_toggle_group() / update_block_toggle_group(): a segmented single/multiple toggle input (view switchers, formatting toolbars) reporting a string (single) or character vector (multiple) to input$<id>. Supports default/outline variants, three sizes, per-choice icons (vendored names or tags) with optional icon-only items, and group-wide or per-item disabling (#93).
block_breadcrumb(), block_breadcrumb_item(), and block_breadcrumb_ellipsis() are new (issue #92): a static page-shell breadcrumb trail following the shadcn breadcrumb pattern. The trail renders a nav[aria-label="breadcrumb"] landmark; separators (default chevron, customizable per trail via separator = as a string or tag) are auto-inserted between entries and hidden from assistive technology; current = TRUE marks the current page with a non-interactive aria-current="page" span; and block_breadcrumb_ellipsis() collapses long middles behind a decorative glyph with a visually hidden label. Pure htmltools markup — no runtime payload or Shiny binding (static links are v1 per the issue; nav-input integration was deliberately deferred).
block_input(type = "number") now has a first-class numeric contract (issue #96). New min, max, and step arguments (number type only) drive increment/decrement stepper buttons rendered inside the control's right edge, the native arrow-key stepping, and stepper bound clamping; update_block_input() accepts the same arguments (NULL clears a bound). input$<id> for number inputs now reports a numeric value like shiny::numericInput() — NA while the field is empty or unparseable — instead of a character string; other types are unchanged.
Horizontal block_tabs() lists now stay within narrow cards and responsive grid columns. Their compact intrinsic width is preserved when labels fit; longer tab rows scroll horizontally instead of overflowing their container.
block_select() and block_combobox() now reject malformed logical flags instead of silently treating them as false; their shared native-select and multi-selection internals have also been consolidated.
block_page() no longer applies Tailwind Preflight or !important focus overrides to every descendant. The generated reset now targets only the app root and shinyblocks-owned sb-* elements, so native Shiny controls, bslib content, and htmlwidgets retain host typography, spacing, borders, media display, and focus treatment in content slots (issue #116).
Alert-dialog focus trapping now uses the same live focusable-element utility as dialog. Native and dynamically rendered inputs, selects, and textareas participate in both tab directions, while disabled, hidden, inert, aria-hidden, and negative-tabindex controls are skipped (issue #115).
block_button() now keeps constructor style in the same controlled channel as update_block_button(), so initial style can be replaced or cleared. Runtime-owned button attributes win over passthrough collisions while title, name, ARIA, and custom data attributes remain supported (issue #114).
Dialogs and alert dialogs now share a LIFO modal manager. In mixed or nested modal stacks, only the top modal handles Escape and traps focus, body scrolling stays locked until the final modal closes, layers follow stack order, and dynamic UI removal safely releases its modal entry (issue #113).
Portaled components now render into a portal owned by their originating runtime mount instead of the first document-global portal. Selects, comboboxes, dialogs, popovers, tooltips, menus, date pickers, and toasters therefore retain the correct scoped theme/style in multi-region dashboards, and dynamic mount removal removes its portal automatically (issue #112).
block_image_output() and block_plot_output() now apply aspect ratio, fit, border, radius, caption, and fill sizing when embedded directly in fluidPage(), bslib, or another host layout; their CSS is rooted narrowly at .sb-output-frame and does not target unrelated Shiny outputs (issue #111).
R-side composition components such as cards, tabs, accordions, breadcrumbs, fields, input groups, navigation, and layout primitives now establish an isolated standalone token scope, so their default theme and style-profile declarations work inside fluidPage(), bslib, and other host layouts without requiring block_page() or styling unrelated host markup (issue #110).
block_date_picker() and block_date_range_picker() now format POSIXct/POSIXlt values in the value's own timezone. Previously they routed through as.Date(), which assumes UTC and could report the wrong calendar date (e.g. a morning timestamp in a UTC+ timezone normalized to the previous day).
block_field_invalid() now generates its error-message ids from an internal counter instead of runif(). Ids stay unique when the app seeds the RNG (previously two fields built after the same set.seed() shared an id, breaking aria-describedby wiring), and building UI no longer consumes from the user's RNG stream.
update_block_*() helpers no longer fail when called through a ...-forwarding wrapper (function(...) update_block_input(...)), a common pattern in module helper code.
Runtime mount ids are now collision-free across input ids that sanitize to the same slug (e.g. "a.b" vs "a-b"); a short hash disambiguates them, so server updates always reach the right element. Ids without special characters are unchanged.
block_select() and update_block_select() now reject grouped (nested) choices with a clear error instead of silently flattening them into mangled "Group.label" labels. Grouped choices were never supported; previously they produced wrong output without warning.
Choice-style arguments are matched exactly everywhere: passing a partial vector of otherwise-valid values (e.g. size = c("lg", "sm")) or a partial string (e.g. type = "pass") is now an error instead of silently resolving to one of them. Function-signature defaults are unaffected.
block_alert_dialog() adds an accessible confirm/cancel overlay with destructive confirmation styling, explicit "confirm" / "cancel" Shiny events, Escape-to-cancel, and no outside-click dismissal (#90).
Server updates for update_block_dialog(class = ) and update_block_file_input(class = ) now use the same payload key as every other component.
The dependency asset-version stamp is computed once per R process instead of stat-ing every bundled asset file on each block_*() call. Set options(shinyblocks.asset_version_cache = FALSE) to recompute per call during asset development.
block_nav() now accepts block_nav_group() collapsible groups and block_nav_label() section captions, letting sidebars express nested navigation while preserving the existing leaf-only Shiny input contract. Nested block_nav_item() values are validated for non-empty unique values in input navs (#80).block_nav() can now act as a Shiny input. Passing block_nav(id = "page") registers input$page; clicking a block_nav_item("Users", value = "users") reports that value, moves the selected highlight, and no longer follows the link — so a sidebar can drive page navigation the way a Shiny tabset does, with pure-R shiny::conditionalPanel() / renderUI() and no custom JavaScript. The new update_block_nav() selects an item from the server (mirroring update_block_tabs()), and block_nav_item() gains a value argument (defaulting to a string label). Without an id the items stay plain links, unchanged. The layout showcase and docs-site playground use it for a working Dashboard/Users switch.block_nav() / block_nav_item() are now demonstrated and documented inside the Layout shell, whose playground (showcase and docs-site) carries a matching "Toggle nav" server-update action and folds the block_nav / block_nav_item API reference into the Layout page.block_tabs(id = ...) and block_nav(id = ...) are now backed by real Shiny InputBindings (shinyblocks.tabs / shinyblocks.nav) instead of the previous Shiny.setInputValue + MutationObserver + sb:tabs/sb:nav custom-message wiring. Behaviour is unchanged for app authors — the inputs still report the selected value, update_block_tabs() / update_block_nav() still select from the server (now via sendInputMessage()), and notify = FALSE still moves the highlight silently — but selection and keyboard handling are delegated at the document, so dynamically inserted tabsets and navs bind and re-bind through Shiny.bindAll with no per-element rewiring. Selecting the already-active item no longer re-fires the input (change semantics, matching Shiny's own tabset input).block_card() spacing after its conversion to plain htmltools markup. Shared style-profile defaults now live on .sb-app as well as runtime roots, restoring card padding, region gaps, heading font, and surface shadow while preserving block_style() overrides.block_sidebar() drawer so its token-backed surface and explicit shell layer remain opaque and interactive above the backdrop, form controls, and plot outputs in narrow app and embedded Shinylive viewports. Desktop collapse styling is now breakpoint-scoped, so a collapsed sidebar opened as a mobile drawer restores its title and navigation labels instead of rendering as an oversized icon-only panel.block_spinner() now supports an icon argument for choosing among loader, pinwheel, refresh, rotate, and dashed-circle spinner glyphs.block_task_button() and update_block_task_button() are new. A package-owned action input that locks itself synchronously on click (rejecting a same-tick double click before React reconciles), shows a busy label and decorative spinner, and reports a shinyActionButtonValue so input$<id> reads like shiny::actionButton(). By default it returns to ready after the reactive flush the click triggered; auto_reset = FALSE keeps it busy until the server releases it. update_block_task_button(state = "busy") takes manual control (an automatic reset will not release it) until state = "ready". The busy state is announced once via a persistent visually-hidden role="status" region; the spinner is decorative. The showcase and docs-site ship an interactive playground (issue #69).block_image_output() and block_plot_output() are new. They add shadcn-styled frames (aspect box, object-fit, border, radius, optional caption) around Shiny's two reactive raster outputs — block_image_output() wraps shiny::imageOutput() (renderImage()); block_plot_output() wraps shiny::plotOutput() (renderPlot(), covering base graphics, ggplot2, and lattice). They are R-side composition primitives: app-author server code stays vanilla Shiny (output$id <- renderImage(...) / renderPlot(...) unchanged) and Shiny keeps owning content serving, resize/recalc, and click/hover/brush coordinates. fill defaults match each underlying Shiny output (FALSE for image, !inline for plot). The image's accessible name (alt) remains server-controlled via renderImage(alt=) / renderPlot(alt=). The showcase and docs-site ship a full interactive playground (Content/State/Styling controls, live renderPlot()/renderImage() demos, and an API table) (issue #67).block_stack(), block_cluster(), and block_grid() as package-owned R layout primitives with semantic spacing, alignment, wrapping, and responsive grid composition (issue #72). block_grid(min_width =) is strictly validated as a single non-negative CSS length or percentage — it rejects calc(), CSS-wide keywords, and any value containing extra declarations, closing the inline-style injection vector that validateCssUnit() left open — and the grid lets its items shrink to their track so wide or unbreakable children no longer overflow on narrow viewports.run_showcase() remains available from a source checkout and now explains clearly when showcase assets are absent.block_alert() gained an action slot via block_alert_action(), plus success, warning, and info variants backed by semantic theme tokens (issue #32, issue #36).block_button() can now be updated from the server with update_block_button(), including label, variant, size, icon, disabled state, style, and class.block_code() gained line numbers, copy-to-clipboard support, an optional editor-style header, and broader syntax highlighting for HTML/XML/SVG, CSS/SCSS/Less, JSON, SQL, shell, R, Python, and JavaScript/TypeScript (issue #32).block_date_picker() and update_block_date_picker() are new. A package-owned date input whose input$<id> is a length-1 Date, matching shiny::dateInput(), but rendered as a shadcn-style trigger button + portaled calendar instead of wrapping the native widget. The calendar is hand-rolled (no react-day-picker/date-fns) with month navigation, keyboard movement (arrows / PageUp / PageDown / Enter / Escape), and min/max day disabling. Accepts Date/POSIX/"yyyy-mm-dd" values, min/max bounds, weekstart, display format, and disabled/invalid states; value = NULL starts empty. The updater supports a clear = TRUE sentinel and clearable min/max. The trigger and portaled calendar are token-driven for full light/dark and block_theme() parity (selected day uses --primary, today and hover use --accent). The showcase and docs-site ship a full interactive playground (Content/State/Actions/Styling controls, live input$ value, and an API table) (issue #59).block_date_range_picker() and update_block_date_range_picker() are new. A package-owned range date input whose input$<id> is a length-2 Date c(start, end), matching shiny::dateRangeInput(), transported over the same shiny.date binding (an empty or incomplete range reports NULL). Accepts Date/POSIX/"yyyy-mm-dd" endpoints, min/max bounds, a display separator, weekstart, display format, and disabled/invalid states; start = NULL/end = NULL starts empty and a reversed start/end is silently ordered. The updater supports independent start/end changes, a clear = TRUE sentinel, and clearable min/max. The trigger and portaled range calendar are token-driven for full light/dark and block_theme() parity (the committed range uses --primary/--accent, the in-progress hover band uses a reduced-emphasis tint). The showcase and docs-site ship a full interactive playground (Content/State/Actions/Styling controls, live input$ value, and an API table) (issue #61).block_file_input() is new. It uses Shiny's native upload binding, supports multiple, accept, disabled/invalid states, custom button text, placeholder text, upload progress, and an optional drag-and-drop variant = "dropzone" (issue #54, issue #56).update_block_file_input() is new. It can update file-input labels, variant, dropzone content, accept/multiple settings, disabled/invalid state, style, class, and can clear the current selection with reset = TRUE (issue #54, issue #56).block_icon() gained semantic color choices and a real size argument ("sm", "default", "lg", and "xl") instead of relying on ad hoc inline dimensions (issue #32).block_progress(), update_block_progress(), and inc_block_progress() are new. An embedded, shadcn-style progress bar that renders inline where it is placed rather than as a Shiny Progress notification panel. It is display-only and server-addressable (addressed by id, with no meaningful input$<id> value): update_block_progress() sets any field (omitted args preserve the client value, NULL clears text fields) and inc_block_progress() applies a signed, saturating increment. Supports a distinct label/message/detail text model, show_value percent, a min/max scale, an indeterminate sweep, and default/success/warning/info/destructive variants. The runtime owns merged-state validity (clamps into the merged [min, max] and repairs an inverted range), the determinate fill animation and the indeterminate sweep both honor prefers-reduced-motion, and the bar is token-driven for full light/dark and block_theme() parity. The showcase and docs-site ship a full interactive playground with Content/State/Actions/Styling controls and an API table (issue #62).block_select() gained multiple = TRUE, matching Shiny's selectInput(multiple = TRUE) value semantics (input$<id> is a length-1 string in single mode, a character vector in multiple mode). The trigger shows selected items as wrapping removable chips and the popup is a checkable, multi-selectable listbox that stays open on toggle, with an optional max_items cap and character(0) defaults. The max_items cap is enforced everywhere: an over-cap initial selected is an error and any later over-cap server selected is clamped. A vector selected reaching a single select collapses to its first element (never "a,b"). For assistive tech, aria-activedescendant lives on the focused combobox trigger. Chips and the multi trigger are fully token-driven for light/dark and block_theme() parity (issue #64).block_select() dropdowns now measure and clamp to the available viewport, fixing clipped options in short embedded playgrounds and under roomier style profiles.block_slider() gained vertical orientation, optional current-value and min/max labels, improved drag update throttling, and better shrink-wrapped layout behavior (issue #22, issue #25).block_spinner() now supports the same semantic foreground colors as block_icon().block_switch() gained size = c("default", "sm", "lg") support in both block_switch() and update_block_switch().block_table() is new. It renders data frames with column formatting, captions, alignment, width overrides, NA handling, max-row truncation notes, reactive server updates via update_block_table(), optional row selection, and theme-safe styling intents (issue #49, issue #51, issue #53).block_textarea() and block_input() now report their initial server value correctly even before React has finished mounting.block_toaster(), show_toast(), dismiss_toast(), and update_block_toaster() are new. Mount a block_toaster() and fire transient, auto-dismissing toast notifications from the server with show_toast(). Toasts stack, pause on hover/focus, support Escape-to-dismiss, and reuse the block_alert() variants (default, destructive, success, warning, info) and icon system. update_block_toaster() moves the region to a new position without re-mounting it, and input$<id> reports the latest toast lifecycle event as a list {action, id, seq}.block_tooltip() is new. It supports hover/focus activation, configurable side and alignment, delayed opening, Escape-to-close, and portal rendering.block_value_box() gained variant = c("default", "accent", "destructive") for token-backed metric emphasis.block_theme() gained scoped overrides, dark-mode-specific token values, and built-in semantic color presets: neutral, stone, zinc, mauve, olive, mist, and taupe (issue #32).block_style() is new, with block_page(style = ) support for visual style profiles that control sizing, spacing, surfaces, radius, elevation, focus rings, and disabled treatment separately from semantic colors (issue #33).block_style_profiles() now reflects the official shadcn/ui v4 style registry: default, luma, lyra, maia, mira, nova, rhea, sera, and vega (issue #48).block_style("luma"), block_style("lyra"), and block_style("rhea") now have broader component and shell coverage, including tables, form controls, tabs, navigation, sidebars, fields, and input groups (issue #33, issue #36).sendInputMessage() path, stricter component-name validation, synchronous DOM value writes, and less duplicated Shiny binding boilerplate (issue #23, issue #24, issue #26, issue #27, issue #28, issue #30, issue #41).update_block_*() helpers now deliver server-driven updates correctly inside a moduleServer(). Previously the runtime mount id (which already carries the module namespace) was re-namespaced by the module session, double-prefixing the target so updates were silently dropped; updates now route through the root session. A new module-routing regression test and a runtime-shiny smoke assertion guard it (issue #63).tools/budget.R now reports runtime JS/CSS raw and gzip sizes separately from legacy compatibility assets.block_page() now includes a package-owned portal root, runtime mounts without Shiny input ids receive unique ids, and static runtime JS tests cover the Shiny bridge, dynamic UI hooks, child binding hooks, and portal setup.renderUI() removal/reinsertion, and Shiny children inside runtime mounts.insertUI() / removeUI() and id reuse.[data-shinyblocks-root] and [data-shinyblocks-portal-root], plus static checks that the runtime asset does not write theme tokens to :root.tools/budget.R.block_badge() and block_button() now render through the package-local React runtime while preserving their R-facing variant, size, icon, custom class, disabled, and passthrough-attribute contracts. Native badge CSS was removed from the legacy stylesheet; native button CSS remains temporarily for block_dark_mode_toggle().block_select() now renders a package-local shadcn-style overlay backed by a hidden native <select> and a component-specific Shiny input binding. update_block_select() now routes through sendInputMessage() and can update value, choices, placeholder, disabled state, styling metadata, and optional Shiny notification.block_separator() now renders through the package-local runtime while preserving horizontal/vertical orientation and decorative/semantic ARIA behavior.block_spinner() now renders through the package-local runtime while preserving the role="status" and accessible label contract.block_skeleton() now renders through the package-local runtime while preserving passthrough attributes and normalized runtime inline-style handling.block_empty() now renders through the package-local runtime while preserving icon, description, extra content, and action composition.block_value_box() now renders through the package-local runtime while preserving title, value, description, icon, and extra-content composition.block_alert() now renders through the package-local runtime while preserving role, variant, icon, title, description, and extra-content composition.block_card() now renders through the package-local runtime. Card body flows through [data-shinyblocks-children] so nested Shiny outputs, htmlwidgets, and inputs bind without a serialized HTML slot. Legacy .sb-card* Tailwind rules have been removed from inst/www/src/shinyblocks.css; runtime CSS now owns card styling under [data-shinyblocks-root].block_dialog(). A modal portal-rendered through [data-shinyblocks-portal-root] with title, description, and serialized body. Initial open state only — no Shiny input binding, no trigger, no escape/outside-click, no focus management.block_dialog() becomes a Shiny input. input$<id> reports the open/closed state as a boolean and updates when the user clicks the trigger, overlay, or close button. New update_block_dialog() updater changes open state, title, and description from the server with explicit notify semantics. The dialog ships its own Shiny.InputBinding (shinyblocks.dialog) and uses the same mount-id-based message routing as block_select().block_popover(). A non-modal, portal-rendered popover anchored to a trigger button via fixed positioning against the trigger's getBoundingClientRect(). Configurable side (bottom / top / left / right) and align (center / start / end). Initial open state only — no Shiny input binding, no outside-click, no escape, no focus management. These arrive in later sub-phases.block_checkbox() now renders through the package runtime (component = "checkbox") with a hidden native checkbox input, a dedicated shinyblocks.checkbox input binding, and browser smoke coverage for user toggles and native-input synchronization.block_switch() now renders through the package runtime (component = "switch") with a hidden native checkbox input, a dedicated shinyblocks.switch input binding, and a new update_block_switch() helper for checked/disabled/style/class server updates with optional notify semantics.block_radio_group() and update_block_radio_group(). Shadcn-style radio group rendered through the package runtime (component = "radio-group") with a hidden native <input type="hidden"> for form-submission, a dedicated shinyblocks.radio-group input binding, full arrow-key navigation (Up/Down/Left/Right), orientation arg ("vertical" / "horizontal"), invalid flag, and a server updater covering selected/choices/disabled/invalid/orientation/style/class with optional notify semantics. Scoped .sb-radio-group-control styles under [data-shinyblocks-root] follow the shadcn radio contract.block_input() and update_block_input(). Single-line text input rendered through the package runtime (component = "input") with a hidden native <input>, a dedicated shinyblocks.input binding (debounced 250 ms), type argument supporting text / password / email / url / tel / search / number, invalid flag, and full server updater for value/placeholder/type/disabled/invalid/style/class with optional notify semantics. Scoped .sb-input-control styles under [data-shinyblocks-root] mirror the shadcn input contract. Showcase ships a full Content/State/Actions/Styling playground.block_textarea() now renders through the package runtime (component = "textarea") with a hidden native <textarea>, a dedicated shinyblocks.textarea input binding (debounced 250 ms), a new invalid argument that sets aria-invalid="true", and a new update_block_textarea() helper for value/placeholder/rows/disabled/invalid/style/class server updates with optional notify semantics. Legacy .sb-textarea rules removed from inst/www/src/shinyblocks.css; runtime CSS owns textarea styling under [data-shinyblocks-root]. Server-driven value / rows updates clear any drag-resized inline style.height so server changes are visually authoritative; the showcase Clear button snaps the preview back to whatever the controls panel currently states.boolean_control_input() helper and the legacy .sb-checkbox-shell/.sb-checkbox-control/.sb-checkbox-indicator/.sb-switch-shell/.sb-switch-control/.sb-switch-track rules from inst/www/src/shinyblocks.css now that all three controls render through the scoped runtime under [data-shinyblocks-root]. Component specs harmonized to the same status header.block_dialog() gains a size argument ("sm", "default", "lg", "xl") that adjusts the content max-width, plus a footer slot that renders below the body in a right-aligned, wrapping flex row. update_block_dialog() learns matching size and footer arguments so server code can resize the dialog or swap action rows without a re-render. Passing footer = NULL to the updater clears an existing footer.block_dialog() is now keyboard- and screen-reader-ready. Escape closes the dialog, Tab/Shift+Tab cycle focus inside the content, focus is moved to the first focusable element on open and returned to whichever element held it before open. Body scroll locks while open with scrollbar-width padding compensation to avoid layout shift. The content carries role="dialog", aria-modal="true", and aria-labelledby / aria-describedby wired to the title and description slots. New hide_title = TRUE argument hides the title visually while keeping it as the accessible name, and the trigger button advertises aria-haspopup="dialog" plus a live aria-expanded.parity/ React reference app, shared Playwright
capture/diff scripts, parity make targets, and the first committed
computed-style baseline for button.make parity now accepts COMPONENT=<name>, and
make parity-ci iterates over every component currently registered
in tools/parity/registry.mjs instead of pretending the button-only
baseline covers the whole package. Phase-exit docs and the
shinyblocks-component skill now describe the actual shared-harness
workflow (parity/src/main.js, registered components, manual spec +
screenshot backstop for everything not yet migrated).block_select() is now migrated into the shared ADR 0016 parity
registry. The dev-only React reference app exposes a local select
trigger in closed and open states, the select parity baseline
is committed as the baseline, and make parity-ci now verifies both
button and select. The selectize wrapper CSS was tightened at the
same time so the visible trigger now matches the shadcn contract more
closely: shadow-xs at rest, justify-between + gap-2, zero
vertical padding inside the fixed 32px shell, and a token-driven open
ring that suppresses Selectize's default blue focus shadow.block_slider() is now migrated into the shared ADR 0016 parity
registry as the first multi-role component. The shared harness now
captures and diffs root, rail, range, and thumb roles across
light/dark, default/hover/disabled states, with extra live checks for
thumb-vs-rail centering and hidden ion.rangeSlider labels. The local
baseline lives at the slider parity baseline, and the
disabled thumb cursor is now forced to not-allowed with
!important so the live widget matches the parity contract.block_checkbox() is now migrated into the shared ADR 0016 parity
registry. The shared harness captures unchecked, checked, and
disabled states across light/dark mode using the visible checkbox
shell plus label-text opacity, with a new disabled checkbox fixture in
the field showcase to anchor the live comparison. The baseline lives
at the checkbox parity baseline.block_switch() is now migrated into the shared ADR 0016 parity
registry. The shared harness captures off, on, and disabled states
across light/dark mode using the visible switch track plus label-text
opacity, with a new disabled switch fixture in the field showcase to
anchor the live comparison. The baseline lives at
the switch parity baseline.block_textarea() is now migrated into the shared ADR 0016 parity
registry. The shared harness captures default, focus-visible,
disabled, and invalid textarea states across light/dark mode, with
stable field-showcase fixtures and a new baseline at
the textarea parity baseline. The shared text-control
CSS was tightened at the same time so text inputs keep shadow-xs
under focus/invalid rings, disabled controls get the shadcn
not-allowed + opacity-50 contract, and block_textarea()
defaults to the 2-row shadcn baseline instead of a taller native
shell.shinyblocks-component lands at
the local shinyblocks-component agent skill
(the tracked canonical copy), with a make skills-install target
that mirrors it into .claude/skills/shinyblocks-component/SKILL.md
and .agents/skills/shinyblocks-component/SKILL.md so Claude Code
and Codex pick it up locally. End-to-end recipe for adding (or
refactoring) a block_*() component: per-gate sync rule (R + CSS
apps/v4/registry/new-york-v4 source, the
parity-harness template (including the positioning + cross-element
bounding-rect checks the 2026-05-11 slider POC surfaced as
necessary), common-pitfall list, and pre-commit checklist.
Triggered by user prompts like "add a component", "port shadcn X
to shinyblocks", or "wrap shiny::Y as a block". the internal roadmap
Current Status and the Phase 5 hand-off doc both point at the
skill as the canonical workflow. make setup now runs
skills-install automatically so a fresh checkout has the skill
registered after one command.tools/parity/slider-poc.mjs grew positioning properties
(position, top, marginTop, transform), a
getBoundingClientRect() cross-check asserting the slider thumb
is vertically centred on the rail (delta ≤ 1.5px), and a dark/
light theme toggle that captures all three roles (rail, range,
thumb) in both modes. The original property-only diff had missed
an off-centre thumb that visibly floated above the rail; the
geometry assertion now catches it.block_slider() CSS hardened to match shadcn's contract under
any container height: .sb-slider .irs--shiny { height: 1.5rem !important; }, inner .irs wrapper expanded to fill via
height: 100% !important, and every role centred with
top: 50% !important; transform: translateY(-50%) !important;
so ion.rangeSlider's inline top rewrites cannot offset them
again.block_select() —
caught
18 trigger drifts and a double-hover dropdown bug that the
spec-doc review missed. A follow-up CSS fix on the same select
reduced trigger drift from 18 to 7 properties (the remaining ones
are colour-space normalisation, expected state mismatches, and
structural divergences by design), and eliminated the double-hover
(dropdown now lights exactly one row on mouse hover instead of
two). The full harness is Slice 6 in
the internal Phase 5 hand-off plan.block_select() trigger and dropdown now match shadcn more
closely: rounded-lg (was rounded-md — 10px vs 4px), transparent
background (was solid), 32px height (was 34px), absolutely-
positioned 16px chevron with the Lucide chevron-down mask, and a
selected option that uses font-weight: 500 instead of a fill so
it no longer competes with the keyboard-cursor / pointer-hover
state.aria-invalid cross-cut, tabs refactor, reference screenshots,
spec backfill, gallery resumption) with files to edit, concrete
CSS patterns, tests to update, and definition-of-done per slice.
The next implementer can pick up Phase 5 finish from there without
rereading the conversation history.apps/v4/registry/new-york-v4 source, three classes of drift were
fixed in inst/www/src/shinyblocks.css: badges now use
rounded-full + text-xs (the current shadcn pill shape, not the
earlier rounded-rectangle), block_button(variant = "link") picks
up text-primary, block_button(variant = "outline") carries
shadow-xs, and destructive button + badge variants use
text-white with a [data-theme="dark"] 60%-opacity dim that
matches shadcn's dark-mode treatment. Three cross-cutting drifts
(focus-ring redesign to the shadcn 3px ring, aria-invalid
styling, tabs data-attribute refactor) are queued as the next
Phase 5 slices.block_*() ships with a short
component spec docs capturing the shadcn reference
link, visual states, token contract, deliberate divergences, and a
reference screenshot. tests/testthat/test-doc-coverage.R enforces
the rule; existing components are backfilled incrementally via a
shrinking backfill_pending_specs allowlist. Quality Gate item 15
walks every state listed in the spec against the live showcase
during phase exit. Seed specs land for block_button() and
block_card(); the template lives at
the component spec template.block_card() now ships with styling for its title, value, and body
slots so it renders as a tokenised surface instead of an unstyled
<article>.block_nav_item() advertises itself as a nav-item child via
data-sb-child, so a future block_nav() parent can validate its
contents.gallery/components/) modelled on
https://shiny.posit.co/r/components/. Each exported component has
a page with an embedded live demo and visible source. See
ADR 0013.inst/showcase/ is now a proper
component gallery — its own UI is built entirely with shinyblocks
primitives, the sidebar filters one component at a time, and every
section is deep-linkable via the URL hash. test-showcase.R
enforces an authoring contract: any new exported block_*() must
land with a matching example file under inst/showcase/R/examples/
and a row in the showcase's sections list, or the test suite fails.block_card() gains composition primitives — block_card_header(),
block_card_title(), block_card_description(),
block_card_content(), block_card_footer() — alongside the
flat-argument convenience form. Pre-built region tags are reused
via data-sb-child markers; bare strings/tags are wrapped
automatically.block_field(), block_field_group(), block_field_invalid(),
block_field_label(), block_field_description(),
block_field_set(), block_field_legend(),
block_input_group(), and block_input_group_addon() add
Phase 5 form wrappers around standard Shiny inputs, including
helper text, addons, fieldset composition, and invalid-state
markup.block_select() now follows the new wrap-by-default input policy:
it is a thin wrapper around Shiny's select/selectize path with
token-driven Selectize styling, instead of a package-owned select
runtime.block_tab() and block_tabs() add shadcn-style tab triggers and
content styling on top of Shiny's existing tabset binding, preserving
reactive tab switching without a custom input runtime.block_theme(), block_dark_mode_toggle(), and
update_block_theme() add page-scoped token overrides, a persistent
dark-mode toggle, and a server-side theme mode updater.block_tabs() now ships local tab activation behavior and hides
inactive panels without relying on Bootstrap tab runtime.block_textarea(), block_checkbox(), and block_switch() add
first-class wrapped Shiny form controls to the forms layer..sb-app *:focus-visible outline fallback.aria-invalid="true" with a
destructive ring on the visible shell, including wrapped select,
textarea, checkbox, and switch inputs.data-state, data-orientation, data-variant) and include the
line variant in the showcase.block_select() also gets
a stronger native-select fallback shell for dark mode.block_select() now applies stronger dark-mode styling to the
Selectize-enhanced trigger itself, and hides the fallback chevron
once Selectize has taken over the control.block_field_group(), block_field(), block_field_label(),
block_field_description(), block_field_set(),
block_field_legend(), and block_field_invalid().block_page(), block_body(), block_header(), and
block_sidebar().block_icon(), block_nav(), block_nav_item(),
block_separator(), block_skeleton(), block_spinner(),
block_empty(), and block_value_box().block_*(); test-doc-coverage.R enforces the spec-doc contract
without a temporary allowlist.≤10 KB) instead of a stale raw/minified threshold.tools/spec-screenshots.R and make spec-screenshots
to report which component-spec screenshots are still missing.make spec-screenshots-md to generate a committed
screenshot queue.make spec-screenshots-check so stale screenshot
queue docs fail fast.make gate and the phase-exit checklist now include the
screenshot-queue freshness check.make spec-screenshots-seed, a macOS/Safari helper
for first-pass capture of the seed screenshot set (button, card,
select, tabs).40 captured-dated references), and the Safari helper has been
generalized to make spec-screenshots-high-risk and
make spec-screenshots-all for repeatable refreshes.make showcase-capture / tools/capture-showcase-parity.sh
so local light/dark showcase sections can be captured reproducibly
during the parity pass.block_value_box() for
high-signal metrics, block_separator() (horizontal + vertical,
ARIA-aware), block_skeleton() for loading placeholders,
block_spinner() with role="status", and block_empty() with
optional icon, description, and action slots.as_alert_child() and as_component_child()
into a single helper; both call sites now share the same code path.tests/testthat/test-doc-coverage.R enforces a per-gate sync rule:
every exported block_*() must appear in _pkgdown.yml's
reference: section (active) and have a gallery .qmd page under
gallery/components/ (currently skip()'d pending the
WASM resolution per ADR 0013). The ROADMAP grows a §Per-gate
component-sync rule section spelling out which artifacts must land
in the same commit as a new component.vignettes/articles/ to top-level
gallery/. pkgdown 2.x requires Quarto 1.5+ to render .qmd files
inside vignettes/, and the project pins Quarto 1.4 today —
relocating sidesteps the version requirement until the gallery's
WASM blocker (ADR 0013) is resolved. make gallery and the
shinylive Quarto extension move with the directory.make verify and make verify-stop targets. verify builds
pkgdown, launches the showcase on :4321 and the pkgdown site on
:4322 in the background, HTTP-checks both for 200, and leaves
them running so the maintainer can eyeball them. make gate now
ends with verify, so a phase exit cannot tag without both servers
responding. Quality Gate item 14 documents this rule.