From 162a983294bd93d9573d47ff798addfc75d7524a Mon Sep 17 00:00:00 2001 From: Cirdes Date: Thu, 5 Nov 2026 11:23:35 -0300 Subject: [PATCH 1/3] [Feature] Codeblock: match shadcn (Base UI + vega) - CodeblockContent: scroll-fade-x, scrollbar-thin and data-wrap:scroll-fade-y, with the scroll-fade-x/-y utilities ported to the installer and docs CSS. - Copy failures show nothing, as in shadcn; clipboard_error:/error_label: are deprecated and ignored. - Audit: fixture covers header, title, footer and numbered diff lines; selectors and the style/onClick props in the map; baseline at 8a353667. Co-Authored-By: Claude Opus 5.5 --- audit/component_map/codeblock.yml | 14 ++- audit/fixtures/codeblock/content.yml | 19 +++- audit/fixtures/codeblock/rubyui.rb | 21 ++++- audit/fixtures/codeblock/shadcn.tsx | 28 +++++- .../stylesheets/application.tailwind.css | 93 +++++++++++++++++++ .../install/templates/tailwind.css.erb | 93 +++++++++++++++++++ gem/lib/ruby_ui/codeblock/codeblock.rb | 10 +- .../ruby_ui/codeblock/codeblock_content.rb | 4 +- .../ruby_ui/codeblock/codeblock_controller.js | 23 ++--- .../codeblock/codeblock_copy_button.rb | 16 ++-- gem/test/ruby_ui/codeblock_test.rb | 32 ++++++- mcp/data/registry/components/codeblock.json | 8 +- 12 files changed, 317 insertions(+), 44 deletions(-) diff --git a/audit/component_map/codeblock.yml b/audit/component_map/codeblock.yml index 9c83fd3e6..75c8c22b7 100644 --- a/audit/component_map/codeblock.yml +++ b/audit/component_map/codeblock.yml @@ -1,5 +1,5 @@ shadcn: code-block -shadcn_sha: 4aaea19d9cff3e0908e12a8c1d504b8c5ec34610 +shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f kind: static docs_view: codeblock parts: @@ -14,18 +14,24 @@ parts: CodeblockTheme: null selectors: Codeblock: '[data-audit-fixture] [data-slot="code-block"]' + CodeblockHeader: '[data-audit-fixture] [data-slot="code-block-header"]' + CodeblockTitle: '[data-audit-fixture] [data-slot="code-block-title"]' CodeblockActions: '[data-audit-fixture] [data-slot="code-block-actions"]' CodeblockCopyButton: '[data-audit-fixture] [data-slot="code-block-copy-button"]' CodeblockContent: '[data-audit-fixture] [data-slot="code-block-content"]' + CodeblockLine: '[data-audit-fixture] [data-slot="code-block-line"]' + CodeblockFooter: '[data-audit-fixture] [data-slot="code-block-footer"]' props: # Codeblock(code, ...) is a shorthand that also takes CodeblockContent's props. Codeblock: {variant: variant, size: size} - CodeblockContent: {wrap: wrap, lineNumbers: line_numbers, startLine: start_line} - CodeblockCopyButton: {value: value, variant: variant, size: size} + CodeblockContent: {style: style, wrap: wrap, lineNumbers: line_numbers, startLine: start_line} + # onClick: React event prop; in RubyUI a data-action passes through attrs. + CodeblockCopyButton: {value: value, variant: variant, size: size, onClick: null} CodeblockLine: {variant: variant} rubyui_only: # syntax: Rouge highlighting (shadcn's base ships no highlighter). - # clipboard*: the shorthand's copy button on/off and its labels. + # clipboard*: the shorthand's copy button on/off and its labels (clipboard_error: deprecated, ignored). Codeblock: [syntax, clipboard, clipboard_success, clipboard_error, wrap, line_numbers, start_line] CodeblockContent: [syntax] + # error_label: deprecated, ignored (a failed copy shows nothing, as in shadcn). CodeblockCopyButton: [label, copied_label, error_label] diff --git a/audit/fixtures/codeblock/content.yml b/audit/fixtures/codeblock/content.yml index 8d8ab47e0..253d560a6 100644 --- a/audit/fixtures/codeblock/content.yml +++ b/audit/fixtures/codeblock/content.yml @@ -1,11 +1,20 @@ -# One code block with a copy button, as shadcn's code-block example without -# header/footer. Plain text on both sides (shadcn's base has no highlighter; -# RubyUI uses Rouge's plaintext lexer) so syntax colors stay out of Look. -# The root carries data-audit-trigger (static kind: only the closed state is -# collected; the copy flow is driven by hand). +# Three code blocks, the same on both sides: +# 1. Floating copy button (CodeBlockActions as a direct child), no header/footer. +# 2. Header with a title and the copy button, then a footer. +# 3. Numbered lines with the default, added, removed and highlighted variants. +# Plain text everywhere (shadcn's base has no highlighter; RubyUI uses Rouge's +# plaintext lexer) so syntax colors stay out of Look. The first root carries +# data-audit-trigger (static kind: only the closed state is collected; hover +# and the copy flow are driven by hand). lines: - "$ pnpm add ai zod" - "dependencies:" - "+ ai 6.0.12" - "+ zod 4.1.12" - "Done in 2.8s" +title: "install.sh" +footer: "Runs in the project root." +diff: + - "const answer = 41" + - "const answer = 42" + - "console.log(answer)" diff --git a/audit/fixtures/codeblock/rubyui.rb b/audit/fixtures/codeblock/rubyui.rb index f8c937351..59194c29b 100644 --- a/audit/fixtures/codeblock/rubyui.rb +++ b/audit/fixtures/codeblock/rubyui.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module ShadcnAuditFixtures - # RubyUI side of audit/fixtures/codeblock (strings in content.yml). + # RubyUI side of audit/fixtures/codeblock (strings in content.yml): a + # floating copy button, a header + footer, and numbered diff lines. class Codeblock < Phlex::HTML include RubyUI @@ -14,8 +15,24 @@ class Codeblock < Phlex::HTML CODE def view_template - div(class: "w-[32rem] p-6") do + div(class: "flex w-[32rem] flex-col gap-4 p-6") do Codeblock(CODE, syntax: :plaintext, data: {audit_trigger: true}) + Codeblock do + CodeblockHeader do + CodeblockTitle { "install.sh" } + CodeblockActions { CodeblockCopyButton(value: CODE) } + end + CodeblockContent(CODE, syntax: :plaintext) + CodeblockFooter { "Runs in the project root." } + end + Codeblock do + CodeblockContent(line_numbers: true) do + CodeblockLine(variant: :removed) { "const answer = 41" } + CodeblockLine(variant: :added) { "const answer = 42" } + CodeblockLine() + CodeblockLine(variant: :highlighted) { "console.log(answer)" } + end + end end end end diff --git a/audit/fixtures/codeblock/shadcn.tsx b/audit/fixtures/codeblock/shadcn.tsx index c5379eafb..717f26fa4 100644 --- a/audit/fixtures/codeblock/shadcn.tsx +++ b/audit/fixtures/codeblock/shadcn.tsx @@ -1,10 +1,14 @@ -// One code block with a copy button (CodeBlockActions as a direct child, so -// it floats top-right like RubyUI's), no header/footer. Plain text, no highlighter. +// Three code blocks (strings in content.yml): a floating copy button, a +// header + footer, and numbered diff lines. Plain text, no highlighter. import { CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, + CodeBlockFooter, + CodeBlockHeader, + CodeBlockLine, + CodeBlockTitle, } from "@/components/ui/code-block" const code = `$ pnpm add ai zod @@ -16,13 +20,31 @@ Done in 2.8s export default function CodeblockFixture() { return ( -
+
{code} + + + install.sh + + + + + {code} + Runs in the project root. + + + + const answer = 41 + const answer = 42 + + console.log(answer) + +
) } diff --git a/docs/app/assets/stylesheets/application.tailwind.css b/docs/app/assets/stylesheets/application.tailwind.css index 27c8aa82f..e2978fe0d 100644 --- a/docs/app/assets/stylesheets/application.tailwind.css +++ b/docs/app/assets/stylesheets/application.tailwind.css @@ -168,6 +168,99 @@ } } +/* Scroll fade (Codeblock): scroll-fade-x fades the start/end edges while there + is content to that side; scroll-fade-y the top/bottom edges. From shadcn's + scroll-fade-x / scroll-fade-y (MIT). */ +@property --scroll-fade-t { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --scroll-fade-s { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --scroll-fade-e { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll-fade-reveal-t { + from { + --scroll-fade-t: 0px; + } + to { + --scroll-fade-t: var(--_scroll-fade-size-t); + } +} + +@keyframes scroll-fade-reveal-s { + from { + --scroll-fade-s: 0px; + } + to { + --scroll-fade-s: var(--_scroll-fade-size-s); + } +} + +@keyframes scroll-fade-reveal-e { + from { + --scroll-fade-e: var(--_scroll-fade-size-e); + } + to { + --scroll-fade-e: 0px; + } +} + +@utility scroll-fade-x { + --_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --scroll-fade-inline: linear-gradient(to right, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%); + &:where([dir="rtl"], [dir="rtl"] *) { + --scroll-fade-inline: linear-gradient(to left, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%); + } + mask-image: var(--scroll-fade-inline); + mask-composite: intersect; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-s 1ms ease-in-out, scroll-fade-reveal-e 1ms ease-in-out; + animation-timeline: scroll(self inline), scroll(self inline); + animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-s: var(--_scroll-fade-size-s); + --scroll-fade-e: var(--_scroll-fade-size-e); + } +} + +@utility scroll-fade-y { + --_scroll-fade-size-t: var(--scroll-fade-t-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --scroll-fade-block: linear-gradient(to bottom, transparent 0, #000 var(--scroll-fade-t, 0px), #000 calc(100% - var(--scroll-fade-b, 0px)), transparent 100%); + mask-image: var(--scroll-fade-block); + mask-composite: intersect; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-t 1ms ease-in-out, scroll-fade-reveal-b 1ms ease-in-out; + animation-timeline: scroll(self y), scroll(self y); + animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-t: var(--_scroll-fade-size-t); + --scroll-fade-b: var(--_scroll-fade-size-b); + } +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/gem/lib/generators/ruby_ui/install/templates/tailwind.css.erb b/gem/lib/generators/ruby_ui/install/templates/tailwind.css.erb index 58bc11487..ca6a3f029 100644 --- a/gem/lib/generators/ruby_ui/install/templates/tailwind.css.erb +++ b/gem/lib/generators/ruby_ui/install/templates/tailwind.css.erb @@ -161,6 +161,99 @@ } } +/* Scroll fade (Codeblock): scroll-fade-x fades the start/end edges while there + is content to that side; scroll-fade-y the top/bottom edges. From shadcn's + scroll-fade-x / scroll-fade-y (MIT). */ +@property --scroll-fade-t { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --scroll-fade-s { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --scroll-fade-e { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll-fade-reveal-t { + from { + --scroll-fade-t: 0px; + } + to { + --scroll-fade-t: var(--_scroll-fade-size-t); + } +} + +@keyframes scroll-fade-reveal-s { + from { + --scroll-fade-s: 0px; + } + to { + --scroll-fade-s: var(--_scroll-fade-size-s); + } +} + +@keyframes scroll-fade-reveal-e { + from { + --scroll-fade-e: var(--_scroll-fade-size-e); + } + to { + --scroll-fade-e: 0px; + } +} + +@utility scroll-fade-x { + --_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --scroll-fade-inline: linear-gradient(to right, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%); + &:where([dir="rtl"], [dir="rtl"] *) { + --scroll-fade-inline: linear-gradient(to left, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%); + } + mask-image: var(--scroll-fade-inline); + mask-composite: intersect; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-s 1ms ease-in-out, scroll-fade-reveal-e 1ms ease-in-out; + animation-timeline: scroll(self inline), scroll(self inline); + animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-s: var(--_scroll-fade-size-s); + --scroll-fade-e: var(--_scroll-fade-size-e); + } +} + +@utility scroll-fade-y { + --_scroll-fade-size-t: var(--scroll-fade-t-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))); + --scroll-fade-block: linear-gradient(to bottom, transparent 0, #000 var(--scroll-fade-t, 0px), #000 calc(100% - var(--scroll-fade-b, 0px)), transparent 100%); + mask-image: var(--scroll-fade-block); + mask-composite: intersect; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-t 1ms ease-in-out, scroll-fade-reveal-b 1ms ease-in-out; + animation-timeline: scroll(self y), scroll(self y); + animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-t: var(--_scroll-fade-size-t); + --scroll-fade-b: var(--_scroll-fade-size-b); + } +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/gem/lib/ruby_ui/codeblock/codeblock.rb b/gem/lib/ruby_ui/codeblock/codeblock.rb index 835f23c59..d9faef266 100644 --- a/gem/lib/ruby_ui/codeblock/codeblock.rb +++ b/gem/lib/ruby_ui/codeblock/codeblock.rb @@ -21,9 +21,14 @@ class Codeblock < Base SIZES = %i[default sm].freeze + # clipboard_error: is deprecated and ignored: a failed copy shows nothing. def initialize(code = nil, syntax: nil, variant: :default, size: :default, clipboard: true, - clipboard_success: "Copied", clipboard_error: "Copy failed", wrap: false, line_numbers: false, + clipboard_success: "Copied", clipboard_error: nil, wrap: false, line_numbers: false, start_line: 1, **attrs) + unless clipboard_error.nil? + Warning.warn("RubyUI::Codeblock: `clipboard_error:` is deprecated and ignored " \ + "(a failed copy shows nothing, as in shadcn)\n", category: :deprecated) + end @code = code @syntax = syntax @variant = variant.to_s.to_sym @@ -32,7 +37,6 @@ def initialize(code = nil, syntax: nil, variant: :default, size: :default, clipb @size = :default unless SIZES.include?(@size) @clipboard = clipboard @clipboard_success = clipboard_success - @clipboard_error = clipboard_error @content = {wrap: wrap, line_numbers: line_numbers, start_line: start_line} super(**attrs) end @@ -48,7 +52,7 @@ def view_template(&block) def shorthand if @clipboard render CodeblockActions.new do - render CodeblockCopyButton.new(value: @code, copied_label: @clipboard_success, error_label: @clipboard_error) + render CodeblockCopyButton.new(value: @code, copied_label: @clipboard_success) end end render CodeblockContent.new(@code, syntax: @syntax, **@content) diff --git a/gem/lib/ruby_ui/codeblock/codeblock_content.rb b/gem/lib/ruby_ui/codeblock/codeblock_content.rb index c9a310375..e2a23b724 100644 --- a/gem/lib/ruby_ui/codeblock/codeblock_content.rb +++ b/gem/lib/ruby_ui/codeblock/codeblock_content.rb @@ -85,8 +85,8 @@ def default_attrs style: style, data: data, class: [ - "min-h-0 min-w-0 overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none", - "data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap", + "min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none", + "data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap", "group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2", "group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3", "group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5" diff --git a/gem/lib/ruby_ui/codeblock/codeblock_controller.js b/gem/lib/ruby_ui/codeblock/codeblock_controller.js index af15868bd..6f2bd765a 100644 --- a/gem/lib/ruby_ui/codeblock/codeblock_controller.js +++ b/gem/lib/ruby_ui/codeblock/codeblock_controller.js @@ -3,7 +3,8 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="ruby-ui--codeblock" // Copies a CodeblockCopyButton's data-value (or the block's code), then shows // the copied state for two seconds: data-copied, the copied label as -// aria-label and in the live region next to the button. +// aria-label and in the live region next to the button. A failed copy shows +// nothing, as in shadcn. export default class extends Controller { static RESET_AFTER = 2000; @@ -25,29 +26,23 @@ export default class extends Controller { const button = event.currentTarget; const value = button.dataset.value ?? this.code(); - // No Clipboard API outside a secure context (plain http): report it as a failure. - if (!navigator.clipboard?.writeText) { - this.show(button, button.dataset.errorLabel, false); - return; - } + // No Clipboard API outside a secure context (plain http). + if (!navigator.clipboard?.writeText) return; navigator.clipboard .writeText(value) - .then(() => this.show(button, button.dataset.copiedLabel, true)) - .catch(() => this.show(button, button.dataset.errorLabel, false)); + .then(() => this.show(button)) + .catch(() => {}); } code() { return this.element.querySelector("[data-slot=code-block-code]")?.innerText ?? ""; } - show(button, label, copied) { + show(button) { + const label = button.dataset.copiedLabel; clearTimeout(this.timers.get(button)); - if (copied) { - button.dataset.copied = "true"; - } else { - delete button.dataset.copied; - } + button.dataset.copied = "true"; button.setAttribute("aria-label", label); this.status(button, label); diff --git a/gem/lib/ruby_ui/codeblock/codeblock_copy_button.rb b/gem/lib/ruby_ui/codeblock/codeblock_copy_button.rb index 65f5e12fc..ee3b4c21f 100644 --- a/gem/lib/ruby_ui/codeblock/codeblock_copy_button.rb +++ b/gem/lib/ruby_ui/codeblock/codeblock_copy_button.rb @@ -3,15 +3,20 @@ module RubyUI # Copies value: (or, without it, the block's code) to the clipboard. After # copying, the icon turns into a check and the label into copied_label for - # two seconds; a polite live region announces it. Needs a Codeblock around - # it (its Stimulus controller lives on the root). + # two seconds; a polite live region announces it. A failed copy shows + # nothing, as in shadcn. Needs a Codeblock around it (its Stimulus + # controller lives on the root). class CodeblockCopyButton < Base - def initialize(value: nil, label: "Copy", copied_label: "Copied", error_label: "Copy failed", + # error_label: is deprecated and ignored: a failed copy shows nothing. + def initialize(value: nil, label: "Copy", copied_label: "Copied", error_label: nil, variant: :ghost, size: :xs, **attrs) + unless error_label.nil? + Warning.warn("RubyUI::CodeblockCopyButton: `error_label:` is deprecated and ignored " \ + "(a failed copy shows nothing, as in shadcn)\n", category: :deprecated) + end @value = value @label = label @copied_label = copied_label - @error_label = error_label @variant = variant @size = size super(**attrs) @@ -35,8 +40,7 @@ def default_attrs slot: "code-block-copy-button", action: "ruby-ui--codeblock#copy", label: @label, - copied_label: @copied_label, - error_label: @error_label + copied_label: @copied_label } data[:value] = @value unless @value.nil? {aria: {label: @label}, data: data} diff --git a/gem/test/ruby_ui/codeblock_test.rb b/gem/test/ruby_ui/codeblock_test.rb index e283ffb9c..8cf61e0f2 100644 --- a/gem/test/ruby_ui/codeblock_test.rb +++ b/gem/test/ruby_ui/codeblock_test.rb @@ -53,11 +53,31 @@ def test_copy_button_has_accessible_name_value_and_live_region assert_match(/aria-label="Copy"/, output) assert_match(/data-value="a < b"/, output) assert_match(/data-copied-label="Nice one!"/, output) - assert_match(/data-error-label="Copy failed"/, output) + refute_match(/data-error-label/, output) assert_match(/data-action="ruby-ui--codeblock#copy"/, output) assert_match(/<\/span>/, output) end + def test_error_labels_are_deprecated_and_ignored + output = nil + _, err = with_deprecation_warnings do + output = phlex { RubyUI.Codeblock(CODE, syntax: :ruby, clipboard_error: "Nope") } + phlex { RubyUI.Codeblock { RubyUI.CodeblockCopyButton(value: "x", error_label: "Nope") } } + end + + assert_match(/`clipboard_error:` is deprecated/, err) + assert_match(/`error_label:` is deprecated/, err) + refute_match(/Nope/, output) + end + + def test_content_fades_overflowing_edges_with_a_thin_scrollbar + output = phlex { RubyUI.CodeblockContent(CODE) } + + assert_match(/scroll-fade-x/, output) + assert_match(/scrollbar-thin/, output) + assert_match(/data-wrap:scroll-fade-y/, output) + end + def test_variant_and_size output = phlex { RubyUI.Codeblock(CODE, syntax: :ruby, variant: :destructive, size: :sm) } @@ -131,4 +151,14 @@ def test_theme_renders_light_and_dark_rules_without_base_background assert_match(/\.dark \[data-slot=code-block-code\] \.k/, output) refute_match(/\[data-slot=code-block-code\], \[data-slot=code-block-code\] \.w \{/, output) end + + private + + def with_deprecation_warnings(&) + previous = Warning[:deprecated] + Warning[:deprecated] = true + capture_io(&) + ensure + Warning[:deprecated] = previous + end end diff --git a/mcp/data/registry/components/codeblock.json b/mcp/data/registry/components/codeblock.json index ddd05f9fc..232a84953 100644 --- a/mcp/data/registry/components/codeblock.json +++ b/mcp/data/registry/components/codeblock.json @@ -4,7 +4,7 @@ "files": [ { "path": "codeblock.rb", - "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n # A block of code, styled after shadcn's CodeBlock (Base UI + vega).\n #\n # Shorthand: Codeblock(code, syntax: :ruby) renders the code highlighted by\n # Rouge with a copy button in the top-right corner (clipboard: false drops\n # it). Without code it is the root of a composition: CodeblockHeader,\n # CodeblockTitle, CodeblockActions, CodeblockCopyButton, CodeblockContent,\n # CodeblockLine and CodeblockFooter.\n #\n # Syntax colors come from CodeblockTheme, rendered once in the .\n class Codeblock < Base\n VARIANTS = {\n default: \"border border-input text-foreground dark:bg-input/30 \" \\\n \"has-[>[data-slot=code-block-content]:focus-visible]:border-ring\",\n destructive: \"border border-destructive/30 text-destructive dark:border-destructive/40\"\n }.freeze\n\n SIZES = %i[default sm].freeze\n\n def initialize(code = nil, syntax: nil, variant: :default, size: :default, clipboard: true,\n clipboard_success: \"Copied\", clipboard_error: \"Copy failed\", wrap: false, line_numbers: false,\n start_line: 1, **attrs)\n @code = code\n @syntax = syntax\n @variant = variant.to_s.to_sym\n @variant = :default unless VARIANTS.key?(@variant)\n @size = size.to_s.to_sym\n @size = :default unless SIZES.include?(@size)\n @clipboard = clipboard\n @clipboard_success = clipboard_success\n @clipboard_error = clipboard_error\n @content = {wrap: wrap, line_numbers: line_numbers, start_line: start_line}\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n @code.nil? ? block&.call : shorthand\n end\n end\n\n private\n\n def shorthand\n if @clipboard\n render CodeblockActions.new do\n render CodeblockCopyButton.new(value: @code, copied_label: @clipboard_success, error_label: @clipboard_error)\n end\n end\n render CodeblockContent.new(@code, syntax: @syntax, **@content)\n end\n\n def default_attrs\n {\n data: {slot: \"code-block\", variant: @variant, size: @size, controller: \"ruby-ui--codeblock\"},\n class: [\n \"group/code-block relative flex w-full min-w-0 flex-col overflow-hidden rounded-lg text-sm data-[size=sm]:text-xs\",\n \"has-[>[data-slot=code-block-content]:focus-visible]:ring-3 has-[>[data-slot=code-block-content]:focus-visible]:ring-ring/50\",\n VARIANTS.fetch(@variant)\n ]\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n # A block of code, styled after shadcn's CodeBlock (Base UI + vega).\n #\n # Shorthand: Codeblock(code, syntax: :ruby) renders the code highlighted by\n # Rouge with a copy button in the top-right corner (clipboard: false drops\n # it). Without code it is the root of a composition: CodeblockHeader,\n # CodeblockTitle, CodeblockActions, CodeblockCopyButton, CodeblockContent,\n # CodeblockLine and CodeblockFooter.\n #\n # Syntax colors come from CodeblockTheme, rendered once in the .\n class Codeblock < Base\n VARIANTS = {\n default: \"border border-input text-foreground dark:bg-input/30 \" \\\n \"has-[>[data-slot=code-block-content]:focus-visible]:border-ring\",\n destructive: \"border border-destructive/30 text-destructive dark:border-destructive/40\"\n }.freeze\n\n SIZES = %i[default sm].freeze\n\n # clipboard_error: is deprecated and ignored: a failed copy shows nothing.\n def initialize(code = nil, syntax: nil, variant: :default, size: :default, clipboard: true,\n clipboard_success: \"Copied\", clipboard_error: nil, wrap: false, line_numbers: false,\n start_line: 1, **attrs)\n unless clipboard_error.nil?\n Warning.warn(\"RubyUI::Codeblock: `clipboard_error:` is deprecated and ignored \" \\\n \"(a failed copy shows nothing, as in shadcn)\\n\", category: :deprecated)\n end\n @code = code\n @syntax = syntax\n @variant = variant.to_s.to_sym\n @variant = :default unless VARIANTS.key?(@variant)\n @size = size.to_s.to_sym\n @size = :default unless SIZES.include?(@size)\n @clipboard = clipboard\n @clipboard_success = clipboard_success\n @content = {wrap: wrap, line_numbers: line_numbers, start_line: start_line}\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n @code.nil? ? block&.call : shorthand\n end\n end\n\n private\n\n def shorthand\n if @clipboard\n render CodeblockActions.new do\n render CodeblockCopyButton.new(value: @code, copied_label: @clipboard_success)\n end\n end\n render CodeblockContent.new(@code, syntax: @syntax, **@content)\n end\n\n def default_attrs\n {\n data: {slot: \"code-block\", variant: @variant, size: @size, controller: \"ruby-ui--codeblock\"},\n class: [\n \"group/code-block relative flex w-full min-w-0 flex-col overflow-hidden rounded-lg text-sm data-[size=sm]:text-xs\",\n \"has-[>[data-slot=code-block-content]:focus-visible]:ring-3 has-[>[data-slot=code-block-content]:focus-visible]:ring-ring/50\",\n VARIANTS.fetch(@variant)\n ]\n }\n end\n end\nend\n" }, { "path": "codeblock_actions.rb", @@ -12,15 +12,15 @@ }, { "path": "codeblock_content.rb", - "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n # The scrollable code: a focusable
 (so the keyboard can scroll it)\n  # with a  inside.\n  #\n  # Given code, it highlights it with Rouge (syntax: any Rouge lexer name;\n  # plain text without it) and, with line_numbers: true, renders one\n  # CodeblockLine per line, numbered from start_line. Without code, the block\n  # gives its own content, usually CodeblockLines (e.g. a diff with\n  # variant: :added / :removed). wrap: true wraps long lines instead of\n  # scrolling sideways.\n  class CodeblockContent < Base\n    FORMATTER = ::Rouge::Formatters::HTML.new\n\n    def initialize(code = nil, syntax: nil, wrap: false, line_numbers: false, start_line: 1, style: nil, **attrs)\n      @code = code\n      @style = style\n      @syntax = syntax\n      @wrap = wrap\n      @line_numbers = line_numbers\n      @start_line = start_line.to_i\n      super(**attrs)\n    end\n\n    def view_template(&block)\n      pre(**attrs) do\n        code(\n          data: {slot: \"code-block-code\"},\n          class: \"grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0\"\n        ) do\n          if @code.nil?\n            block&.call\n          elsif @line_numbers\n            lines.each { |line| render CodeblockLine.new { raw(safe(line)) } }\n          else\n            # One grid item: Rouge's spans would each take a row of the grid.\n            span { raw(safe(FORMATTER.format(lexer.lex(@code)))) }\n          end\n        end\n      end\n    end\n\n    private\n\n    def lexer\n      (@syntax && ::Rouge::Lexer.find(@syntax.to_s)) || ::Rouge::Lexers::PlainText\n    end\n\n    # Highlighted HTML per line, without the trailing newline, so each line's\n    # spans close inside its CodeblockLine.\n    def lines\n      lines = [[]]\n      lexer.lex(@code.chomp).each do |token, value|\n        value.split(/(\\n)/).each do |part|\n          if part == \"\\n\"\n            lines << []\n          elsif !part.empty?\n            lines.last << [token, part]\n          end\n        end\n      end\n      lines.map { |tokens| FORMATTER.format(tokens) }\n    end\n\n    # One declaration list: Phlex's mix would join our string with the\n    # caller's style without a separator. The caller's style comes last, so it\n    # wins, as in shadcn ({counterReset, ...style}).\n    def style\n      own = \"counter-reset: line #{@start_line - 1};\"\n      theirs = @style.is_a?(Hash) ? @style.map { |k, v| \"#{k.to_s.tr(\"_\", \"-\")}: #{v};\" }.join(\" \") : @style.to_s.strip\n      theirs = \"#{theirs};\" unless theirs.empty? || theirs.end_with?(\";\")\n      [own, theirs].reject(&:empty?).join(\" \")\n    end\n\n    def default_attrs\n      data = {slot: \"code-block-content\"}\n      data[:wrap] = true if @wrap\n      data[:numbered] = true if @line_numbers\n      {\n        dir: \"ltr\",\n        tabindex: 0,\n        style: style,\n        data: data,\n        class: [\n          \"min-h-0 min-w-0 overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none\",\n          \"data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap\",\n          \"group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2\",\n          \"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3\",\n          \"group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5\"\n        ]\n      }\n    end\n  end\nend\n"
+      "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n  # The scrollable code: a focusable 
 (so the keyboard can scroll it)\n  # with a  inside.\n  #\n  # Given code, it highlights it with Rouge (syntax: any Rouge lexer name;\n  # plain text without it) and, with line_numbers: true, renders one\n  # CodeblockLine per line, numbered from start_line. Without code, the block\n  # gives its own content, usually CodeblockLines (e.g. a diff with\n  # variant: :added / :removed). wrap: true wraps long lines instead of\n  # scrolling sideways.\n  class CodeblockContent < Base\n    FORMATTER = ::Rouge::Formatters::HTML.new\n\n    def initialize(code = nil, syntax: nil, wrap: false, line_numbers: false, start_line: 1, style: nil, **attrs)\n      @code = code\n      @style = style\n      @syntax = syntax\n      @wrap = wrap\n      @line_numbers = line_numbers\n      @start_line = start_line.to_i\n      super(**attrs)\n    end\n\n    def view_template(&block)\n      pre(**attrs) do\n        code(\n          data: {slot: \"code-block-code\"},\n          class: \"grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0\"\n        ) do\n          if @code.nil?\n            block&.call\n          elsif @line_numbers\n            lines.each { |line| render CodeblockLine.new { raw(safe(line)) } }\n          else\n            # One grid item: Rouge's spans would each take a row of the grid.\n            span { raw(safe(FORMATTER.format(lexer.lex(@code)))) }\n          end\n        end\n      end\n    end\n\n    private\n\n    def lexer\n      (@syntax && ::Rouge::Lexer.find(@syntax.to_s)) || ::Rouge::Lexers::PlainText\n    end\n\n    # Highlighted HTML per line, without the trailing newline, so each line's\n    # spans close inside its CodeblockLine.\n    def lines\n      lines = [[]]\n      lexer.lex(@code.chomp).each do |token, value|\n        value.split(/(\\n)/).each do |part|\n          if part == \"\\n\"\n            lines << []\n          elsif !part.empty?\n            lines.last << [token, part]\n          end\n        end\n      end\n      lines.map { |tokens| FORMATTER.format(tokens) }\n    end\n\n    # One declaration list: Phlex's mix would join our string with the\n    # caller's style without a separator. The caller's style comes last, so it\n    # wins, as in shadcn ({counterReset, ...style}).\n    def style\n      own = \"counter-reset: line #{@start_line - 1};\"\n      theirs = @style.is_a?(Hash) ? @style.map { |k, v| \"#{k.to_s.tr(\"_\", \"-\")}: #{v};\" }.join(\" \") : @style.to_s.strip\n      theirs = \"#{theirs};\" unless theirs.empty? || theirs.end_with?(\";\")\n      [own, theirs].reject(&:empty?).join(\" \")\n    end\n\n    def default_attrs\n      data = {slot: \"code-block-content\"}\n      data[:wrap] = true if @wrap\n      data[:numbered] = true if @line_numbers\n      {\n        dir: \"ltr\",\n        tabindex: 0,\n        style: style,\n        data: data,\n        class: [\n          \"min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none\",\n          \"data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap\",\n          \"group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2\",\n          \"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3\",\n          \"group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5\"\n        ]\n      }\n    end\n  end\nend\n"
     },
     {
       "path": "codeblock_controller.js",
-      "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--codeblock\"\n// Copies a CodeblockCopyButton's data-value (or the block's code), then shows\n// the copied state for two seconds: data-copied, the copied label as\n// aria-label and in the live region next to the button.\nexport default class extends Controller {\n  static RESET_AFTER = 2000;\n\n  connect() {\n    this.timers = new Map();\n    // A cached page (Turbo snapshot) can come back mid-feedback: start clean.\n    this.buttons().forEach((button) => this.reset(button));\n  }\n\n  disconnect() {\n    this.timers.forEach((timer, button) => {\n      clearTimeout(timer);\n      this.reset(button);\n    });\n    this.timers.clear();\n  }\n\n  copy(event) {\n    const button = event.currentTarget;\n    const value = button.dataset.value ?? this.code();\n\n    // No Clipboard API outside a secure context (plain http): report it as a failure.\n    if (!navigator.clipboard?.writeText) {\n      this.show(button, button.dataset.errorLabel, false);\n      return;\n    }\n\n    navigator.clipboard\n      .writeText(value)\n      .then(() => this.show(button, button.dataset.copiedLabel, true))\n      .catch(() => this.show(button, button.dataset.errorLabel, false));\n  }\n\n  code() {\n    return this.element.querySelector(\"[data-slot=code-block-code]\")?.innerText ?? \"\";\n  }\n\n  show(button, label, copied) {\n    clearTimeout(this.timers.get(button));\n    if (copied) {\n      button.dataset.copied = \"true\";\n    } else {\n      delete button.dataset.copied;\n    }\n    button.setAttribute(\"aria-label\", label);\n    this.status(button, label);\n\n    this.timers.set(button, setTimeout(() => {\n      this.timers.delete(button);\n      this.reset(button);\n    }, this.constructor.RESET_AFTER));\n  }\n\n  reset(button) {\n    delete button.dataset.copied;\n    button.setAttribute(\"aria-label\", button.dataset.label);\n    this.status(button, \"\");\n  }\n\n  buttons() {\n    return this.element.querySelectorAll(\"[data-slot=code-block-copy-button]\");\n  }\n\n  status(button, text) {\n    const status = button.nextElementSibling;\n    if (status?.dataset.slot === \"code-block-copy-status\") status.textContent = text;\n  }\n}\n"
+      "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--codeblock\"\n// Copies a CodeblockCopyButton's data-value (or the block's code), then shows\n// the copied state for two seconds: data-copied, the copied label as\n// aria-label and in the live region next to the button. A failed copy shows\n// nothing, as in shadcn.\nexport default class extends Controller {\n  static RESET_AFTER = 2000;\n\n  connect() {\n    this.timers = new Map();\n    // A cached page (Turbo snapshot) can come back mid-feedback: start clean.\n    this.buttons().forEach((button) => this.reset(button));\n  }\n\n  disconnect() {\n    this.timers.forEach((timer, button) => {\n      clearTimeout(timer);\n      this.reset(button);\n    });\n    this.timers.clear();\n  }\n\n  copy(event) {\n    const button = event.currentTarget;\n    const value = button.dataset.value ?? this.code();\n\n    // No Clipboard API outside a secure context (plain http).\n    if (!navigator.clipboard?.writeText) return;\n\n    navigator.clipboard\n      .writeText(value)\n      .then(() => this.show(button))\n      .catch(() => {});\n  }\n\n  code() {\n    return this.element.querySelector(\"[data-slot=code-block-code]\")?.innerText ?? \"\";\n  }\n\n  show(button) {\n    const label = button.dataset.copiedLabel;\n    clearTimeout(this.timers.get(button));\n    button.dataset.copied = \"true\";\n    button.setAttribute(\"aria-label\", label);\n    this.status(button, label);\n\n    this.timers.set(button, setTimeout(() => {\n      this.timers.delete(button);\n      this.reset(button);\n    }, this.constructor.RESET_AFTER));\n  }\n\n  reset(button) {\n    delete button.dataset.copied;\n    button.setAttribute(\"aria-label\", button.dataset.label);\n    this.status(button, \"\");\n  }\n\n  buttons() {\n    return this.element.querySelectorAll(\"[data-slot=code-block-copy-button]\");\n  }\n\n  status(button, text) {\n    const status = button.nextElementSibling;\n    if (status?.dataset.slot === \"code-block-copy-status\") status.textContent = text;\n  }\n}\n"
     },
     {
       "path": "codeblock_copy_button.rb",
-      "content": "# frozen_string_literal: true\n\nmodule RubyUI\n  # Copies value: (or, without it, the block's code) to the clipboard. After\n  # copying, the icon turns into a check and the label into copied_label for\n  # two seconds; a polite live region announces it. Needs a Codeblock around\n  # it (its Stimulus controller lives on the root).\n  class CodeblockCopyButton < Base\n    def initialize(value: nil, label: \"Copy\", copied_label: \"Copied\", error_label: \"Copy failed\",\n      variant: :ghost, size: :xs, **attrs)\n      @value = value\n      @label = label\n      @copied_label = copied_label\n      @error_label = error_label\n      @variant = variant\n      @size = size\n      super(**attrs)\n    end\n\n    def view_template\n      button = Button.new(variant: @variant, size: @size, icon: true, **attrs)\n      # mix joins strings, so the Button's own slot would read \"button code-block-copy-button\".\n      button.attrs[:data][:slot] = \"code-block-copy-button\"\n      render button do\n        copy_icon\n        check_icon\n      end\n      span(aria_live: \"polite\", class: \"sr-only\", data: {slot: \"code-block-copy-status\"})\n    end\n\n    private\n\n    def default_attrs\n      data = {\n        slot: \"code-block-copy-button\",\n        action: \"ruby-ui--codeblock#copy\",\n        label: @label,\n        copied_label: @copied_label,\n        error_label: @error_label\n      }\n      data[:value] = @value unless @value.nil?\n      {aria: {label: @label}, data: data}\n    end\n\n    # lucide copy and check, as in shadcn; the button's data-copied picks one.\n    def copy_icon\n      icon(class: \"group-data-copied/button:hidden\") do |s|\n        s.rect(width: \"14\", height: \"14\", x: \"8\", y: \"8\", rx: \"2\", ry: \"2\")\n        s.path(d: \"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\")\n      end\n    end\n\n    def check_icon\n      icon(class: \"hidden group-data-copied/button:block\") do |s|\n        s.path(d: \"M20 6 9 17l-5-5\")\n      end\n    end\n\n    def icon(**, &)\n      svg(\n        xmlns: \"http://www.w3.org/2000/svg\",\n        width: \"24\",\n        height: \"24\",\n        viewbox: \"0 0 24 24\",\n        fill: \"none\",\n        stroke: \"currentColor\",\n        stroke_width: \"2\",\n        stroke_linecap: \"round\",\n        stroke_linejoin: \"round\",\n        aria_hidden: \"true\",\n        **, &\n      )\n    end\n  end\nend\n"
+      "content": "# frozen_string_literal: true\n\nmodule RubyUI\n  # Copies value: (or, without it, the block's code) to the clipboard. After\n  # copying, the icon turns into a check and the label into copied_label for\n  # two seconds; a polite live region announces it. A failed copy shows\n  # nothing, as in shadcn. Needs a Codeblock around it (its Stimulus\n  # controller lives on the root).\n  class CodeblockCopyButton < Base\n    # error_label: is deprecated and ignored: a failed copy shows nothing.\n    def initialize(value: nil, label: \"Copy\", copied_label: \"Copied\", error_label: nil,\n      variant: :ghost, size: :xs, **attrs)\n      unless error_label.nil?\n        Warning.warn(\"RubyUI::CodeblockCopyButton: `error_label:` is deprecated and ignored \" \\\n          \"(a failed copy shows nothing, as in shadcn)\\n\", category: :deprecated)\n      end\n      @value = value\n      @label = label\n      @copied_label = copied_label\n      @variant = variant\n      @size = size\n      super(**attrs)\n    end\n\n    def view_template\n      button = Button.new(variant: @variant, size: @size, icon: true, **attrs)\n      # mix joins strings, so the Button's own slot would read \"button code-block-copy-button\".\n      button.attrs[:data][:slot] = \"code-block-copy-button\"\n      render button do\n        copy_icon\n        check_icon\n      end\n      span(aria_live: \"polite\", class: \"sr-only\", data: {slot: \"code-block-copy-status\"})\n    end\n\n    private\n\n    def default_attrs\n      data = {\n        slot: \"code-block-copy-button\",\n        action: \"ruby-ui--codeblock#copy\",\n        label: @label,\n        copied_label: @copied_label\n      }\n      data[:value] = @value unless @value.nil?\n      {aria: {label: @label}, data: data}\n    end\n\n    # lucide copy and check, as in shadcn; the button's data-copied picks one.\n    def copy_icon\n      icon(class: \"group-data-copied/button:hidden\") do |s|\n        s.rect(width: \"14\", height: \"14\", x: \"8\", y: \"8\", rx: \"2\", ry: \"2\")\n        s.path(d: \"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\")\n      end\n    end\n\n    def check_icon\n      icon(class: \"hidden group-data-copied/button:block\") do |s|\n        s.path(d: \"M20 6 9 17l-5-5\")\n      end\n    end\n\n    def icon(**, &)\n      svg(\n        xmlns: \"http://www.w3.org/2000/svg\",\n        width: \"24\",\n        height: \"24\",\n        viewbox: \"0 0 24 24\",\n        fill: \"none\",\n        stroke: \"currentColor\",\n        stroke_width: \"2\",\n        stroke_linecap: \"round\",\n        stroke_linejoin: \"round\",\n        aria_hidden: \"true\",\n        **, &\n      )\n    end\n  end\nend\n"
     },
     {
       "path": "codeblock_footer.rb",

From 67fc7e8ebb91f73e440627bcf3fbac256456fbc5 Mon Sep 17 00:00:00 2001
From: Cirdes 
Date: Thu, 5 Nov 2026 11:35:34 -0300
Subject: [PATCH 2/3] [Feature] Codeblock: register the onClick divergence

Co-Authored-By: Claude Opus 5.5 
---
 audit/divergences/codeblock.yml | 6 ++++++
 1 file changed, 6 insertions(+)
 create mode 100644 audit/divergences/codeblock.yml

diff --git a/audit/divergences/codeblock.yml b/audit/divergences/codeblock.yml
new file mode 100644
index 000000000..8ebd089e1
--- /dev/null
+++ b/audit/divergences/codeblock.yml
@@ -0,0 +1,6 @@
+- component: codeblock
+  dimension: Props
+  rubyui: "no onClick on CodeblockCopyButton; a data-action passed through attrs runs alongside the copy"
+  shadcn: "onClick on CodeBlockCopyButton, called before the copy"
+  reason: "Server-rendered component: there is no React event prop. Extra behavior hooks in through Stimulus with data: {action: \"click->my-controller#method\"}, merged with the button's own copy action."
+  since: "2026-11-05"

From 844118ee2953a45a616745982b4f14e3626dcfea Mon Sep 17 00:00:00 2001
From: Cirdes 
Date: Thu, 5 Nov 2026 11:44:24 -0300
Subject: [PATCH 3/3] [Documentation] Add scroll-fade-x/-y to the manual
 install CSS

Co-Authored-By: Claude Opus 5.5 
---
 .../views/docs/installation/rails_bundler.rb  | 92 +++++++++++++++++++
 .../docs/installation/rails_importmaps.rb     | 92 +++++++++++++++++++
 2 files changed, 184 insertions(+)

diff --git a/docs/app/views/docs/installation/rails_bundler.rb b/docs/app/views/docs/installation/rails_bundler.rb
index 3838df456..d7d51a5ad 100644
--- a/docs/app/views/docs/installation/rails_bundler.rb
+++ b/docs/app/views/docs/installation/rails_bundler.rb
@@ -388,6 +388,98 @@ def default_attrs
               }
 
 
+              /* Scroll fade (Codeblock): scroll-fade-x fades the start/end edges while there
+                 is content to that side; scroll-fade-y the top/bottom edges. From shadcn's
+                 scroll-fade-x / scroll-fade-y (MIT). */
+              @property --scroll-fade-t {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @property --scroll-fade-s {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @property --scroll-fade-e {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @keyframes scroll-fade-reveal-t {
+                from {
+                  --scroll-fade-t: 0px;
+                }
+                to {
+                  --scroll-fade-t: var(--_scroll-fade-size-t);
+                }
+              }
+
+              @keyframes scroll-fade-reveal-s {
+                from {
+                  --scroll-fade-s: 0px;
+                }
+                to {
+                  --scroll-fade-s: var(--_scroll-fade-size-s);
+                }
+              }
+
+              @keyframes scroll-fade-reveal-e {
+                from {
+                  --scroll-fade-e: var(--_scroll-fade-size-e);
+                }
+                to {
+                  --scroll-fade-e: 0px;
+                }
+              }
+
+              @utility scroll-fade-x {
+                --_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --scroll-fade-inline: linear-gradient(to right, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%);
+                &:where([dir="rtl"], [dir="rtl"] *) {
+                  --scroll-fade-inline: linear-gradient(to left, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%);
+                }
+                mask-image: var(--scroll-fade-inline);
+                mask-composite: intersect;
+                mask-repeat: no-repeat;
+
+                @supports (animation-timeline: scroll()) {
+                  animation: scroll-fade-reveal-s 1ms ease-in-out, scroll-fade-reveal-e 1ms ease-in-out;
+                  animation-timeline: scroll(self inline), scroll(self inline);
+                  animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%;
+                  animation-fill-mode: both;
+                }
+
+                @supports not (animation-timeline: scroll()) {
+                  --scroll-fade-s: var(--_scroll-fade-size-s);
+                  --scroll-fade-e: var(--_scroll-fade-size-e);
+                }
+              }
+
+              @utility scroll-fade-y {
+                --_scroll-fade-size-t: var(--scroll-fade-t-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --scroll-fade-block: linear-gradient(to bottom, transparent 0, #000 var(--scroll-fade-t, 0px), #000 calc(100% - var(--scroll-fade-b, 0px)), transparent 100%);
+                mask-image: var(--scroll-fade-block);
+                mask-composite: intersect;
+                mask-repeat: no-repeat;
+
+                @supports (animation-timeline: scroll()) {
+                  animation: scroll-fade-reveal-t 1ms ease-in-out, scroll-fade-reveal-b 1ms ease-in-out;
+                  animation-timeline: scroll(self y), scroll(self y);
+                  animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%;
+                  animation-fill-mode: both;
+                }
+
+                @supports not (animation-timeline: scroll()) {
+                  --scroll-fade-t: var(--_scroll-fade-size-t);
+                  --scroll-fade-b: var(--_scroll-fade-size-b);
+                }
+              }
               @layer base {
                 * {
                   @apply border-border outline-ring/50;
diff --git a/docs/app/views/docs/installation/rails_importmaps.rb b/docs/app/views/docs/installation/rails_importmaps.rb
index d4045bcec..3469205ae 100644
--- a/docs/app/views/docs/installation/rails_importmaps.rb
+++ b/docs/app/views/docs/installation/rails_importmaps.rb
@@ -388,6 +388,98 @@ def default_attrs
                 }
               }
 
+              /* Scroll fade (Codeblock): scroll-fade-x fades the start/end edges while there
+                 is content to that side; scroll-fade-y the top/bottom edges. From shadcn's
+                 scroll-fade-x / scroll-fade-y (MIT). */
+              @property --scroll-fade-t {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @property --scroll-fade-s {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @property --scroll-fade-e {
+                syntax: "";
+                inherits: false;
+                initial-value: 0px;
+              }
+
+              @keyframes scroll-fade-reveal-t {
+                from {
+                  --scroll-fade-t: 0px;
+                }
+                to {
+                  --scroll-fade-t: var(--_scroll-fade-size-t);
+                }
+              }
+
+              @keyframes scroll-fade-reveal-s {
+                from {
+                  --scroll-fade-s: 0px;
+                }
+                to {
+                  --scroll-fade-s: var(--_scroll-fade-size-s);
+                }
+              }
+
+              @keyframes scroll-fade-reveal-e {
+                from {
+                  --scroll-fade-e: var(--_scroll-fade-size-e);
+                }
+                to {
+                  --scroll-fade-e: 0px;
+                }
+              }
+
+              @utility scroll-fade-x {
+                --_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --scroll-fade-inline: linear-gradient(to right, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%);
+                &:where([dir="rtl"], [dir="rtl"] *) {
+                  --scroll-fade-inline: linear-gradient(to left, transparent 0, #000 var(--scroll-fade-s, 0px), #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%);
+                }
+                mask-image: var(--scroll-fade-inline);
+                mask-composite: intersect;
+                mask-repeat: no-repeat;
+
+                @supports (animation-timeline: scroll()) {
+                  animation: scroll-fade-reveal-s 1ms ease-in-out, scroll-fade-reveal-e 1ms ease-in-out;
+                  animation-timeline: scroll(self inline), scroll(self inline);
+                  animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%;
+                  animation-fill-mode: both;
+                }
+
+                @supports not (animation-timeline: scroll()) {
+                  --scroll-fade-s: var(--_scroll-fade-size-s);
+                  --scroll-fade-e: var(--_scroll-fade-size-e);
+                }
+              }
+
+              @utility scroll-fade-y {
+                --_scroll-fade-size-t: var(--scroll-fade-t-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
+                --scroll-fade-block: linear-gradient(to bottom, transparent 0, #000 var(--scroll-fade-t, 0px), #000 calc(100% - var(--scroll-fade-b, 0px)), transparent 100%);
+                mask-image: var(--scroll-fade-block);
+                mask-composite: intersect;
+                mask-repeat: no-repeat;
+
+                @supports (animation-timeline: scroll()) {
+                  animation: scroll-fade-reveal-t 1ms ease-in-out, scroll-fade-reveal-b 1ms ease-in-out;
+                  animation-timeline: scroll(self y), scroll(self y);
+                  animation-range: 0 calc(var(--spacing) * 24), calc(100% - calc(var(--spacing) * 24)) 100%;
+                  animation-fill-mode: both;
+                }
+
+                @supports not (animation-timeline: scroll()) {
+                  --scroll-fade-t: var(--_scroll-fade-size-t);
+                  --scroll-fade-b: var(--_scroll-fade-size-b);
+                }
+              }
               @layer base {
                 * {
                   @apply border-border outline-ring/50;