lostcoords-ui 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -89
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +20 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/components.css +25 -0
  9. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  10. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  11. data/app/assets/tailwind/lostcoords/ui/disclosure.css +5 -5
  12. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +43 -43
  13. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  14. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  15. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  16. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  17. data/app/assets/tailwind/lostcoords/ui/reading.css +24 -24
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +60 -60
  19. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  20. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  21. data/app/assets/tailwind/lostcoords/ui/theme.css +194 -225
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +50 -0
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +37 -61
  24. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  25. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  26. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  27. data/app/helpers/lostcoords/ui/button_helper.rb +36 -33
  28. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  29. data/app/helpers/lostcoords/ui/choice_helper.rb +83 -0
  30. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  31. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  32. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  33. data/app/helpers/lostcoords/ui/dialog_helper.rb +6 -13
  34. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  35. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  36. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  37. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  38. data/app/helpers/lostcoords/ui/menu_helper.rb +8 -8
  39. data/app/helpers/lostcoords/ui/navigation_helper.rb +131 -0
  40. data/app/helpers/lostcoords/ui/overlay_helper.rb +17 -0
  41. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  42. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  43. data/app/helpers/lostcoords/ui/sheet_helper.rb +3 -45
  44. data/app/helpers/lostcoords/ui/structures_helper.rb +55 -64
  45. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  46. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  47. data/app/helpers/lostcoords/ui/typography_helper.rb +11 -11
  48. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  49. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +3 -3
  50. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  51. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  52. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +4 -4
  53. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +3 -3
  54. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  55. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +1 -1
  56. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +15 -15
  57. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +39 -0
  58. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  59. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +14 -0
  60. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +5 -5
  61. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  62. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  63. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +7 -7
  64. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +2 -2
  65. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  66. data/app/javascript/lostcoords/ui/index.js +51 -0
  67. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +30 -30
  68. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  69. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  70. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  71. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  72. data/config/helper_renames.json +23 -25
  73. data/config/importmap.rb +10 -2
  74. data/config/locales/en.yml +23 -0
  75. data/config/locales/es.yml +23 -0
  76. data/config/locales/ja.yml +23 -0
  77. data/lib/lostcoords/ui/action_view.rb +5 -3
  78. data/lib/lostcoords/ui/builders.rb +1 -1
  79. data/lib/lostcoords/ui/form_builder.rb +113 -0
  80. data/lib/lostcoords/ui/parts.rb +154 -0
  81. data/lib/lostcoords/ui/tokens.rb +24 -1
  82. data/lib/lostcoords/ui/version.rb +1 -1
  83. metadata +33 -17
  84. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  85. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  86. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  87. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  88. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  89. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  90. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -1,45 +1,37 @@
1
- /* The rim is the border box painted with --pill-rim under the surface, so
2
- one element carries both layers. */
3
- @utility pill-3d {
4
- border: calc(var(--spacing) * 0.375) solid transparent;
5
- background: var(--pill-surface) padding-box, var(--pill-rim) border-box;
6
- box-shadow: var(--pill-light), var(--pill-depth);
7
- }
8
-
9
1
  /* A control gives a little as it is pressed, unless it is disabled. */
10
- @utility press {
2
+ @utility lc-press {
11
3
  @media (prefers-reduced-motion: no-preference) {
12
4
  &:not([disabled], :disabled, [aria-disabled="true"]):active {
13
- transform: scale(var(--press-scale, var(--ui-press-scale)));
5
+ transform: scale(var(--lc-press-scale));
14
6
  }
15
7
  }
16
8
  }
17
9
 
18
10
  @layer components {
19
11
  /* A data-URI image cannot read CSS variables, so the chevron's ink is baked in. */
20
- select.select-control {
12
+ select.lc-select {
21
13
  appearance: none;
22
14
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%237b808a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
23
15
  background-size: calc(var(--spacing) * 5) calc(var(--spacing) * 5);
24
16
  background-repeat: no-repeat;
25
- background-position: right calc(var(--spacing) * 4) center;
17
+ background-position: right calc(var(--spacing) * 3) center;
26
18
  }
27
19
  }
28
20
 
29
- @property --scroll-fade-start { syntax: "<length>"; inherits: false; initial-value: 0; }
30
- @property --scroll-fade-end { syntax: "<length>"; inherits: false; initial-value: 0; }
21
+ @property --lc-scroll-fade-start { syntax: "<length>"; inherits: false; initial-value: 0; }
22
+ @property --lc-scroll-fade-end { syntax: "<length>"; inherits: false; initial-value: 0; }
31
23
 
32
- @keyframes scroll-fade-x {
33
- 0% { --scroll-fade-start: 0; --scroll-fade-end: 2rem; }
34
- 15% { --scroll-fade-start: 2rem; }
35
- 85% { --scroll-fade-end: 2rem; }
36
- 100% { --scroll-fade-start: 2rem; --scroll-fade-end: 0; }
24
+ @keyframes lc-scroll-fade-x {
25
+ 0% { --lc-scroll-fade-start: 0; --lc-scroll-fade-end: 2rem; }
26
+ 15% { --lc-scroll-fade-start: 2rem; }
27
+ 85% { --lc-scroll-fade-end: 2rem; }
28
+ 100% { --lc-scroll-fade-start: 2rem; --lc-scroll-fade-end: 0; }
37
29
  }
38
30
 
39
31
  @supports (animation-timeline: scroll(self inline)) {
40
- .scroll-fade-x {
41
- mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black calc(100% - var(--scroll-fade-end)), transparent);
42
- animation: scroll-fade-x linear both;
32
+ .lc-scroll-fade-x {
33
+ mask-image: linear-gradient(to right, transparent, black var(--lc-scroll-fade-start), black calc(100% - var(--lc-scroll-fade-end)), transparent);
34
+ animation: lc-scroll-fade-x linear both;
43
35
  animation-timeline: scroll(self inline);
44
36
  }
45
37
  }
@@ -1,128 +1,96 @@
1
1
  module Lostcoords::Ui
2
2
  module BadgeHelper
3
3
  ICON_BADGE_BASE =
4
- "icon-badge squircle grid place-items-center shrink-0 " \
5
- "size-[var(--icon-badge-size,var(--icon-badge-size-default,--spacing(10)))] " \
6
- "[border:var(--icon-badge-border,1px_solid_transparent)] " \
7
- "rounded-[var(--icon-badge-radius,var(--radius-2xl))] " \
8
- "bg-[var(--icon-badge-bg,var(--paper-2))] " \
9
- "text-[length:var(--icon-badge-font-size,var(--icon-badge-font-size-default,1.125rem))] leading-none"
10
-
11
- # Sizes set the default, so a context that pins --icon-badge-size still wins.
12
- ICON_BADGE_SIZES = {
13
- base: "",
14
- lg: "[--icon-badge-size-default:--spacing(11)] [--icon-badge-font-size-default:1.25rem]",
15
- xl: "[--icon-badge-size-default:--spacing(14)] [--icon-badge-font-size-default:1.5rem]"
16
- }.freeze
17
-
18
- ICON_MEDALLION_BASE = "grid place-items-center shrink-0 rounded-full"
4
+ "lc-icon-badge grid place-items-center shrink-0 " \
5
+ "size-[var(--lc-icon-badge-size,var(--lc-icon-badge-size-default,--spacing(10)))] " \
6
+ "[border:var(--lc-icon-badge-border,1px_solid_transparent)] " \
7
+ "text-[length:var(--lc-icon-badge-font-size,var(--lc-icon-badge-font-size-default,1.125rem))] leading-none"
19
8
 
20
- ICON_MEDALLION_SIZES = {
21
- sm: "size-6.5",
22
- lg: "size-12"
9
+ # Sizes set the default, so a context that pins --lc-icon-badge-size still wins.
10
+ ICON_BADGE_SIZES = {
11
+ sm: "[--lc-icon-badge-size-default:--spacing(6.5)] [--lc-icon-badge-font-size-default:0.8125rem]",
12
+ md: "",
13
+ lg: "[--lc-icon-badge-size-default:--spacing(12)] [--lc-icon-badge-font-size-default:1.5rem]",
14
+ xl: "[--lc-icon-badge-size-default:--spacing(14)] [--lc-icon-badge-font-size-default:1.75rem]"
23
15
  }.freeze
24
16
 
25
- ICON_MEDALLION_TONES = {
26
- accent: "bg-sky-soft text-accent",
27
- neutral: "bg-paper-2",
28
- leaf: "bg-[color-mix(in_oklab,var(--leaf-soft)_72%,var(--paper))] text-leaf"
29
- }.freeze
17
+ ICON_BADGE_SHAPES = { square: "lc-squircle rounded-2xl", circle: "rounded-full" }.freeze
30
18
 
31
19
  AVATAR_BASE =
32
- "avatar grid place-items-center shrink-0 rounded-full font-medium tracking-[var(--avatar-tracking,0.05em)] " \
33
- "size-[var(--avatar-size,var(--avatar-size-default,--spacing(8)))] " \
34
- "text-[length:var(--avatar-font-size,var(--avatar-font-size-default,0.75rem))] " \
35
- "text-[color:var(--avatar-color,var(--ink-on-accent))] " \
36
- "border-[length:var(--avatar-border,0px)] border-[color:var(--avatar-cutout,var(--paper))]"
20
+ "lc-avatar grid place-items-center shrink-0 rounded-full font-medium tracking-[var(--lc-avatar-tracking,0.05em)] " \
21
+ "size-[var(--lc-avatar-size,var(--lc-avatar-size-default,--spacing(8)))] " \
22
+ "text-[length:var(--lc-avatar-font-size,var(--lc-avatar-font-size-default,0.75rem))] " \
23
+ "text-[color:var(--lc-avatar-color,var(--primary-foreground))] " \
24
+ "border-[length:var(--lc-avatar-border,0px)] border-[color:var(--lc-avatar-cutout,var(--background))]"
37
25
 
38
- # Sizes set the default, so a context that pins --avatar-size (a stack, a dialog) still wins.
26
+ # Sizes set the default, so a context that pins --lc-avatar-size (a stack, a dialog) still wins.
39
27
  AVATAR_SIZES = {
40
- base: "",
41
- lg: "",
42
- sm: "[--avatar-size-default:--spacing(6.5)] [--avatar-font-size-default:0.625rem]",
43
- xl: "[--avatar-size-default:--spacing(24)] [--avatar-font-size-default:1.25rem]"
28
+ md: "",
29
+ lg: "[--lc-avatar-size-default:--spacing(12)] [--lc-avatar-font-size-default:1rem]",
30
+ sm: "[--lc-avatar-size-default:--spacing(6.5)] [--lc-avatar-font-size-default:0.625rem]",
31
+ xl: "[--lc-avatar-size-default:--spacing(24)] [--lc-avatar-font-size-default:1.25rem]"
44
32
  }.freeze
45
33
 
46
- AVATAR_INK = "[--avatar-color:var(--ink)]"
34
+ AVATAR_INK = "[--lc-avatar-color:var(--foreground)]"
47
35
 
48
- AVATAR_LINKED = "avatar-ring"
36
+ AVATAR_LINKED = "lc-avatar-ring"
49
37
 
50
38
  # Overlapping avatars with a "+N" chip. The stack sets inherited variables rather than styling
51
- # its avatars, so a context's own `[&_.avatar]:` override still wins. The border cuts each
52
- # avatar out of the one beneath, so it takes the fill it sits on (--avatar-cutout).
39
+ # its avatars, so a context's own `[&_.lc-avatar]:` override still wins. The border cuts each
40
+ # avatar out of the one beneath, so it takes the fill it sits on (--lc-avatar-cutout).
53
41
  AVATAR_STACK_BASE =
54
- "avatar-stack relative inline-flex items-center min-w-0 [&_.avatar]:outline-none " \
55
- "[--avatar-size:--spacing(8)] [--avatar-border:--spacing(0.5)] [--avatar-ring-gap:1] " \
56
- "[--avatar-stack-overlap:--spacing(1.5)]"
42
+ "lc-avatar-stack relative inline-flex items-center min-w-0 [&_.lc-avatar]:outline-none " \
43
+ "[--lc-avatar-size:--spacing(8)] [--lc-avatar-border:--spacing(0.5)] [--lc-avatar-ring-gap:1] " \
44
+ "[--lc-avatar-stack-overlap:--spacing(1.5)]"
57
45
 
58
46
  AVATAR_STACK_COMPACT =
59
- "[--avatar-size:--spacing(4.5)] [--avatar-font-size:0.4375rem] [--avatar-border:0px] [--avatar-ring-gap:2] " \
60
- "@sm:[--avatar-size:--spacing(5.5)] @sm:[--avatar-font-size:0.5rem] " \
61
- "[--avatar-stack-overlap:--spacing(1.25)] [--avatar-stack-padding:0px]"
47
+ "[--lc-avatar-size:--spacing(4.5)] [--lc-avatar-font-size:0.4375rem] [--lc-avatar-border:0px] [--lc-avatar-ring-gap:2] " \
48
+ "@sm:[--lc-avatar-size:--spacing(5.5)] @sm:[--lc-avatar-font-size:0.5rem] " \
49
+ "[--lc-avatar-stack-overlap:--spacing(1.25)] [--lc-avatar-stack-padding:0px]"
62
50
 
63
51
  # dynamic_avatar_stack_controller marks the leading visible avatar with data-first as it narrows the stack.
64
52
  AVATAR_STACK_ROW =
65
- "avatar-stack__row flex shrink-0 p-[var(--avatar-stack-padding,--spacing(1))] " \
66
- "[&>.avatar]:-ms-(--avatar-stack-overlap) [&>.avatar:first-child]:ms-0 [&>.avatar[data-first]]:ms-0"
67
-
68
- EYEBROW_BASE =
69
- "font-mono uppercase tracking-widest text-xs font-medium text-ink-3"
53
+ "lc-avatar-stack__row flex shrink-0 p-[var(--lc-avatar-stack-padding,--spacing(1))] " \
54
+ "[&>.lc-avatar]:-ms-(--lc-avatar-stack-overlap) [&>.lc-avatar:first-child]:ms-0 [&>.lc-avatar[data-first]]:ms-0"
70
55
 
71
56
  BADGE_BASE =
72
- "inline-flex items-center gap-1 rounded-xl px-2.5 py-1 " \
73
- "font-body text-xs font-medium whitespace-nowrap"
57
+ "inline-flex items-center gap-tight rounded-lg lc-squircle px-2 py-0.5 " \
58
+ "font-sans text-caption font-medium whitespace-nowrap"
74
59
 
75
60
  BADGE_TONES = {
76
- neutral: "bg-paper-2 text-ink-2",
77
- success: "bg-leaf-soft text-leaf",
78
- warning: "bg-gold-soft text-gold",
79
- danger: "bg-berry-soft text-berry",
80
- info: "bg-sky-soft text-sky-deep"
61
+ neutral: "bg-muted text-muted-foreground",
62
+ success: "bg-success-soft text-success",
63
+ warning: "bg-warning-soft text-warning",
64
+ destructive: "bg-destructive-soft text-destructive",
65
+ primary: "bg-primary-soft text-primary"
81
66
  }.freeze
82
67
 
83
- STATUS_PILL_BASE =
84
- "inline-flex items-center rounded-full px-[0.6em] py-[0.15em] " \
85
- "font-body text-sm font-medium"
86
-
87
- STATUS_PILL_TONES = {
88
- accent: "bg-sky-soft text-accent",
89
- danger: "bg-berry-soft text-berry"
90
- }.freeze
91
-
92
- PILL_BASE =
93
- "inline-flex items-center justify-center gap-2 rounded-full " \
94
- "border border-transparent bg-paper-2 " \
95
- "font-body text-ink-2 whitespace-nowrap cursor-pointer no-underline " \
96
- "transition-[background-color,border-color,color,transform] duration-[150ms] ease-out " \
97
- "hover:text-ink active:scale-[0.96] " \
98
- "aria-pressed:[background:var(--sky-soft)] aria-pressed:border-accent aria-pressed:text-accent aria-pressed:shadow-none " \
99
- "aria-[current]:not-aria-[current=false]:[background:var(--sky-soft)] aria-[current]:not-aria-[current=false]:border-accent " \
100
- "aria-[current]:not-aria-[current=false]:text-accent aria-[current]:not-aria-[current=false]:shadow-none " \
101
- "has-checked:[background:var(--sky-soft)] has-checked:border-accent has-checked:text-accent has-checked:shadow-none " \
68
+ TOGGLE_BASE =
69
+ "inline-flex items-center justify-center gap-related rounded-pill " \
70
+ "border border-transparent bg-input " \
71
+ "font-sans text-muted-foreground whitespace-nowrap cursor-pointer no-underline " \
72
+ "transition-[background-color,border-color,color,transform] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
73
+ "lc-press hover:text-foreground " \
74
+ "aria-pressed:bg-primary-soft aria-pressed:border-primary aria-pressed:text-primary aria-pressed:shadow-none " \
75
+ "aria-[current]:not-aria-[current=false]:bg-primary-soft aria-[current]:not-aria-[current=false]:border-primary " \
76
+ "aria-[current]:not-aria-[current=false]:text-primary aria-[current]:not-aria-[current=false]:shadow-none " \
77
+ "has-checked:bg-primary-soft has-checked:border-primary has-checked:text-primary has-checked:shadow-none " \
102
78
  "[&_svg]:size-[1.1em] [&_svg]:shrink-0"
103
79
 
104
- PILL_ACTIVE = "bg-sky-soft border-accent text-accent hover:text-accent shadow-none"
80
+ TOGGLE_ACTIVE = "bg-primary-soft border-primary text-primary hover:text-primary shadow-none"
105
81
 
106
- PILL_GHOST = "border-dashed border-ink-3/40 [background:none] shadow-none text-ink-3"
82
+ TOGGLE_GHOST = "border-dashed border-muted-foreground/40 [background:none] shadow-none text-muted-foreground"
107
83
 
108
- PILL_ROW_BASE =
109
- "flex flex-wrap items-center gap-y-2 gap-x-2"
110
-
111
- def lc_icon_badge_class(size: :base, extra: nil)
112
- lc_tw(ICON_BADGE_BASE, ICON_BADGE_SIZES.fetch(size.to_sym), extra)
84
+ def lc_icon_badge_class(size: :md, shape: :square, tone: :neutral, extra: nil)
85
+ size = lc_option!(:size, size, ICON_BADGE_SIZES.keys)
86
+ shape = lc_option!(:shape, shape, ICON_BADGE_SHAPES.keys)
87
+ tone = lc_option!(:tone, tone, BADGE_TONES.keys)
88
+ lc_tw(ICON_BADGE_BASE, ICON_BADGE_SIZES.fetch(size), ICON_BADGE_SHAPES.fetch(shape),
89
+ (tone == :neutral ? "bg-muted text-foreground" : BADGE_TONES.fetch(tone)), extra)
113
90
  end
114
91
 
115
- def lc_icon_medallion_class(size: :sm, tone: :accent, extra: nil)
116
- lc_tw(
117
- ICON_MEDALLION_BASE,
118
- ICON_MEDALLION_SIZES.fetch(size.to_sym, ICON_MEDALLION_SIZES[:sm]),
119
- ICON_MEDALLION_TONES.fetch(tone.to_sym, ICON_MEDALLION_TONES[:accent]),
120
- extra
121
- )
122
- end
123
-
124
- def lc_avatar_class(size: :base, ink: false, linked: false, extra: nil)
125
- lc_tw(AVATAR_BASE, AVATAR_SIZES.fetch(size.to_sym), (AVATAR_INK if ink), (AVATAR_LINKED if linked), extra)
92
+ def lc_avatar_class(size: :md, ink: false, linked: false, extra: nil)
93
+ lc_tw(AVATAR_BASE, AVATAR_SIZES.fetch(lc_option!(:size, size, AVATAR_SIZES.keys)), (AVATAR_INK if ink), (AVATAR_LINKED if linked), extra)
126
94
  end
127
95
 
128
96
  def lc_avatar_stack_class(compact: false, extra: nil)
@@ -133,30 +101,19 @@ module Lostcoords::Ui
133
101
  lc_tw(AVATAR_STACK_ROW, extra)
134
102
  end
135
103
 
136
- def lc_eyebrow_class(extra: nil)
137
- lc_tw(EYEBROW_BASE, extra)
138
- end
139
-
140
104
  def lc_badge_class(tone: :neutral, extra: nil)
141
- lc_tw(BADGE_BASE, BADGE_TONES.fetch(tone.to_sym, BADGE_TONES[:neutral]), extra)
142
- end
143
-
144
- def lc_status_pill_class(tone: :accent, extra: nil)
145
- lc_tw(STATUS_PILL_BASE, STATUS_PILL_TONES.fetch(tone.to_sym, STATUS_PILL_TONES[:accent]), extra)
105
+ tone = lc_option!(:tone, tone, BADGE_TONES.keys)
106
+ lc_tw(BADGE_BASE, BADGE_TONES.fetch(tone), extra)
146
107
  end
147
108
 
148
- def lc_pill_class(active: false, ghost: false, size: :base, extra: nil)
109
+ def lc_toggle_class(active: false, ghost: false, size: :md, extra: nil)
149
110
  lc_tw(
150
- PILL_BASE,
151
- ButtonHelper::BUTTON_SIZES.fetch(size.to_sym, ButtonHelper::BUTTON_SIZES[:base]),
152
- (PILL_GHOST if ghost),
153
- (PILL_ACTIVE if active),
111
+ TOGGLE_BASE,
112
+ ButtonHelper::BUTTON_SIZES.fetch(lc_option!(:size, size, ButtonHelper::BUTTON_SIZES.keys)),
113
+ (TOGGLE_GHOST if ghost),
114
+ (TOGGLE_ACTIVE if active),
154
115
  extra
155
116
  )
156
117
  end
157
-
158
- def lc_pill_row_class(extra: nil)
159
- lc_tw(PILL_ROW_BASE, extra)
160
- end
161
118
  end
162
119
  end
@@ -1,70 +1,73 @@
1
1
  module Lostcoords::Ui
2
2
  module ButtonHelper
3
3
  BUTTON_BASE =
4
- "inline-flex items-center justify-center gap-2 rounded-full font-body font-medium " \
4
+ "inline-flex items-center justify-center gap-related rounded-pill font-sans " \
5
5
  "whitespace-nowrap no-underline cursor-pointer " \
6
- "transition-[background-color,border-color,color,box-shadow,transform,translate,scale,filter] duration-(--ui-motion-control) ease-out " \
6
+ "transition-[background-color,border-color,color,box-shadow,transform,translate,scale,filter] duration-(--lc-motion-control) ease-(--lc-ease-out) " \
7
7
  "motion-safe:hover:-translate-y-px motion-safe:active:translate-y-px active:shadow-none disabled:opacity-40 disabled:pointer-events-none " \
8
8
  "[&_svg]:block [&_svg:not([class*='size-'])]:size-[1.1em] [&_svg]:shrink-0"
9
9
 
10
+ # Height comes from the shared control sizes; an icon trims the padding on its side.
10
11
  BUTTON_SIZES = {
11
- sm: "#{TailwindHelper::CONTROL_SIZES[:sm]} px-4 has-[>[data-icon=inline-start]]:ps-3 has-[>[data-icon=inline-end]]:pe-3 text-sm",
12
- base: "#{TailwindHelper::CONTROL_SIZES[:base]} px-5 has-[>[data-icon=inline-start]]:ps-4 has-[>[data-icon=inline-end]]:pe-4 text-base",
13
- lg: "#{TailwindHelper::CONTROL_SIZES[:lg]} px-7 has-[>[data-icon=inline-start]]:ps-6 has-[>[data-icon=inline-end]]:pe-6 text-base",
14
- nav: "#{TailwindHelper::CONTROL_SIZES[:sm]} px-4 has-[>[data-icon=inline-start]]:ps-3 has-[>[data-icon=inline-end]]:pe-3 text-base"
12
+ sm: "#{TailwindHelper::CONTROL_SIZES[:sm]} px-3 has-[>[data-icon=inline-start]]:ps-2.5 has-[>[data-icon=inline-end]]:pe-2.5 text-label",
13
+ md: "#{TailwindHelper::CONTROL_SIZES[:md]} px-4 has-[>[data-icon=inline-start]]:ps-3.5 has-[>[data-icon=inline-end]]:pe-3.5 text-label",
14
+ lg: "#{TailwindHelper::CONTROL_SIZES[:lg]} px-5 has-[>[data-icon=inline-start]]:ps-4 has-[>[data-icon=inline-end]]:pe-4 text-body font-medium"
15
15
  }.freeze
16
16
 
17
+ BUTTON_HOVER =
18
+ "hover:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))] " \
19
+ "active:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))]"
20
+
17
21
  BUTTON_VARIANTS = {
18
- default: "border border-transparent bg-paper text-ink active:bg-paper-2",
19
- accent: "border border-transparent bg-sky text-ink-on-accent hover:bg-sky-deep active:bg-sky-deep",
20
- secondary: "border border-transparent bg-paper-2 text-ink hover:bg-paper-3 active:bg-paper-3",
21
- glass: "pill-3d text-ink hover:brightness-[0.98] active:brightness-[0.96]",
22
- ghost: "border border-transparent bg-transparent text-ink-2 hover:bg-(--ghost-hover-bg,var(--paper-2)) hover:text-ink active:bg-(--ghost-hover-bg,var(--paper-2))",
23
- danger: "border border-transparent bg-transparent text-berry hover:bg-berry/8 active:bg-berry/8"
22
+ primary: "border border-transparent [--lc-button-bg:var(--primary)] [--lc-button-ink:var(--primary-foreground)] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}",
23
+ secondary: "border border-transparent [--lc-button-bg:var(--secondary)] [--lc-button-ink:var(--secondary-foreground)] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}",
24
+ outline: "border border-border bg-transparent text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
25
+ glass: "[--lc-button-bg:var(--_lc-glass-fill)] text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
26
+ ghost: "border border-transparent bg-transparent text-muted-foreground hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
27
+ destructive: "border border-transparent [--lc-button-bg:color-mix(in_oklab,var(--destructive)_60%,black)] [--lc-button-ink:#fff] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}"
24
28
  }.freeze
25
29
 
26
30
  ICON_BUTTON_BASE =
27
- "inline-grid place-items-center shrink-0 border-0 bg-transparent " \
28
- "text-ink-3 cursor-pointer no-underline " \
29
- "transition-[background-color,color,transform,scale] duration-(--ui-motion-quick) ease-out " \
30
- "hover:bg-paper-2 hover:text-ink motion-safe:active:scale-(--ui-press-scale) " \
31
+ "lc-press inline-grid place-items-center shrink-0 border-0 bg-transparent " \
32
+ "text-muted-foreground cursor-pointer no-underline " \
33
+ "transition-[background-color,color,transform,scale] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
34
+ "hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER} " \
31
35
  "disabled:opacity-40 disabled:pointer-events-none " \
32
36
  "[&_svg]:block [&_svg]:shrink-0"
33
37
 
34
38
  ICON_BUTTON_SHAPES = {
35
- square: "rounded-xl",
36
- circle: "rounded-full"
39
+ square: "rounded-xl lc-squircle",
40
+ circle: "rounded-pill"
37
41
  }.freeze
38
42
 
39
43
  ICON_BUTTON_SIZES = {
40
- sm: "size-11 [&_svg]:size-4",
41
- base: "size-12 [&_svg]:size-5",
42
- lg: "size-14 [&_svg]:size-6"
44
+ sm: "size-control-sm [&_svg]:size-4",
45
+ md: "size-control-md [&_svg]:size-5",
46
+ lg: "size-control-lg [&_svg]:size-6"
43
47
  }.freeze
44
48
 
45
- def lc_button_class(variant: :default, size: :base, block: false, extra: nil)
49
+ def lc_button_class(variant: :primary, size: :md, block: false, extra: nil)
50
+ variant = lc_option!(:variant, variant, BUTTON_VARIANTS.keys)
51
+ size = lc_option!(:size, size, BUTTON_SIZES.keys)
46
52
  lc_tw(
47
53
  BUTTON_BASE,
48
- BUTTON_SIZES.fetch(size.to_sym, BUTTON_SIZES[:base]),
49
- lc_button_variants.fetch(variant.to_sym, lc_button_variants[:default]),
54
+ BUTTON_SIZES.fetch(size),
55
+ (lc_glass_class(shape: :bar) if variant == :glass),
56
+ BUTTON_VARIANTS.fetch(variant),
50
57
  ("w-full" if block),
51
58
  extra
52
59
  )
53
60
  end
54
61
 
55
- def lc_icon_button_class(shape: :square, size: :base, extra: nil)
62
+ def lc_icon_button_class(shape: :square, size: :md, extra: nil)
63
+ shape = lc_option!(:shape, shape, ICON_BUTTON_SHAPES.keys)
64
+ size = lc_option!(:size, size, ICON_BUTTON_SIZES.keys)
56
65
  lc_tw(
57
66
  ICON_BUTTON_BASE,
58
- ICON_BUTTON_SHAPES.fetch(shape.to_sym, ICON_BUTTON_SHAPES[:square]),
59
- ICON_BUTTON_SIZES.fetch(size.to_sym, ICON_BUTTON_SIZES[:base]),
67
+ ICON_BUTTON_SHAPES.fetch(shape),
68
+ ICON_BUTTON_SIZES.fetch(size),
60
69
  extra
61
70
  )
62
71
  end
63
-
64
- private
65
-
66
- def lc_button_variants
67
- BUTTON_VARIANTS
68
- end
69
72
  end
70
73
  end
@@ -1,60 +1,17 @@
1
1
  module Lostcoords::Ui
2
2
  module CardHelper
3
- CARD_BASE =
4
- "squircle bg-paper-2 border border-transparent rounded-3xl"
5
-
3
+ # Padding sizes set --lc-inset, so nested content that pads or gaps with inset follows the card.
6
4
  CARD_PADDING = {
7
- default: "p-5",
8
- dense: "py-4 px-3.5 sm:p-5"
9
- }.freeze
10
-
11
- HOVER_LIFT =
12
- "transition-[transform,box-shadow] duration-[180ms] ease-out " \
13
- "hover:-translate-y-0.5 hover:shadow-(--shadow)"
14
-
15
- CARD_SURFACE_CHROME =
16
- "squircle bg-(--card-surface-bg,var(--paper-2)) " \
17
- "[border:var(--card-surface-border,1px_solid_transparent)] " \
18
- "rounded-(--card-surface-radius,var(--radius-3xl)) " \
19
- "shadow-(--card-surface-shadow,none) [--field-bg:var(--paper)] " \
20
- "[--avatar-cutout:var(--card-surface-bg,var(--paper-2))] " \
21
- "transition-[background,border-color,box-shadow] duration-[180ms] ease-(--ease-out)"
22
-
23
- FORM_CARD_BASE =
24
- "form-card @container " \
25
- "#{CARD_SURFACE_CHROME} " \
26
- "has-[[data-tone=error]]:[border-color:color-mix(in_oklab,var(--berry)_30%,var(--hairline))]"
27
-
28
- FORM_CARD_PADDINGS = {
29
- default: "p-(--form-card-padding,1.25rem)",
30
- compact: "p-(--form-card-padding,1rem_14px) sm:p-(--form-card-padding,20px)",
31
- snug: "p-(--form-card-padding,1rem_14px) sm:p-(--form-card-padding,1rem_20px)",
32
- empty: "p-(--form-card-padding,2rem_1.25rem)"
5
+ none: "p-0",
6
+ md: "p-inset",
7
+ sm: "[--lc-inset:var(--spacing-content)] p-inset"
33
8
  }.freeze
9
+ HOVER_LIFT = "transition-[transform,box-shadow] duration-(--lc-motion-control) ease-(--lc-ease-out) hover:-translate-y-0.5 hover:shadow-(--lc-shadow)"
34
10
 
35
- FORM_CARD_EMPTY_CONTENT = "text-center text-ink-3"
36
-
37
- def lc_card_class(interactive: false, dense: false, extra: nil)
38
- lc_tw(
39
- CARD_BASE,
40
- CARD_PADDING.fetch(dense ? :dense : :default),
41
- (HOVER_LIFT if interactive),
42
- ("hover:bg-paper-3" if interactive),
43
- extra
44
- )
45
- end
46
-
47
- def lc_card_surface_class(extra: nil)
48
- lc_tw(CARD_SURFACE_CHROME, extra)
49
- end
50
-
51
- def lc_form_card_class(padding: :default, extra: nil)
52
- lc_tw(
53
- FORM_CARD_BASE,
54
- FORM_CARD_PADDINGS.fetch(padding.to_sym, FORM_CARD_PADDINGS[:default]),
55
- (FORM_CARD_EMPTY_CONTENT if padding.to_sym == :empty),
56
- extra
57
- )
11
+ def lc_card_class(interactive: false, padding: :md, extra: nil)
12
+ padding = lc_option!(:padding, padding, CARD_PADDING.keys)
13
+ lc_tw("lc-card lc-squircle", CARD_PADDING.fetch(padding),
14
+ ("#{HOVER_LIFT} hover:bg-accent" if interactive), extra)
58
15
  end
59
16
  end
60
17
  end
@@ -0,0 +1,83 @@
1
+ module Lostcoords::Ui
2
+ module ChoiceHelper
3
+ CHOICE_BASE = "shrink-0 size-4.5 m-0 cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-40"
4
+
5
+ # The label row centers the control on the first line of text, so a hint wraps under the label.
6
+ CHOICE_LABEL = "lc-choice inline-flex min-w-0 items-start gap-related text-body text-foreground cursor-pointer has-disabled:cursor-not-allowed has-disabled:opacity-60"
7
+
8
+ # card: true draws the whole label as a selectable tile, for plan or theme pickers.
9
+ CHOICE_CARD = "flex w-full rounded-2xl lc-squircle border border-transparent bg-(--lc-tile-bg,var(--muted)) p-content " \
10
+ "transition-[background-color,border-color] duration-(--lc-motion-quick) ease-(--lc-ease-out) hover:bg-accent " \
11
+ "has-checked:border-primary has-checked:bg-primary-soft has-focus-visible:outline-2 has-focus-visible:outline-offset-2 " \
12
+ "has-focus-visible:outline-(--ring,color-mix(in_oklab,var(--primary)_50%,transparent))"
13
+
14
+ def lc_choice_label_class(card: false, extra: nil)
15
+ lc_tw(CHOICE_LABEL, (CHOICE_CARD if card), extra)
16
+ end
17
+
18
+ def lc_checkbox_class(extra: nil)
19
+ lc_tw("lc-checkbox rounded-sm", CHOICE_BASE, extra)
20
+ end
21
+
22
+ def lc_radio_class(extra: nil)
23
+ lc_tw("lc-radio rounded-full", CHOICE_BASE, extra)
24
+ end
25
+
26
+ def lc_switch_class(extra: nil)
27
+ lc_tw("lc-switch shrink-0 m-0 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40", extra)
28
+ end
29
+
30
+ # A native checkbox. unchecked_value: submits a value when unchecked, like Rails' check_box; pass nil to omit it.
31
+ # A block adds content under the label, such as a preview inside a card: true tile.
32
+ def lc_checkbox(label, name: nil, value: "1", checked: false, unchecked_value: "0", hint: nil, card: false, **attributes, &block)
33
+ lc_choice("checkbox", label, hint, card, name, unchecked_value, { type: "checkbox", name: name, value: value, checked: checked, class: lc_checkbox_class }, attributes, &block)
34
+ end
35
+
36
+ def lc_radio(label, name:, value:, checked: false, hint: nil, card: false, **attributes, &block)
37
+ lc_choice("radio", label, hint, card, name, nil, { type: "radio", name: name, value: value, checked: checked, class: lc_radio_class }, attributes, &block)
38
+ end
39
+
40
+ # A checkbox drawn as a switch. It submits with the form, like a checkbox.
41
+ def lc_switch(label, name: nil, value: "1", checked: false, unchecked_value: "0", hint: nil, **attributes)
42
+ lc_choice("switch", label, hint, false, name, unchecked_value, { type: "checkbox", role: "switch", name: name, value: value, checked: checked, class: lc_switch_class }, attributes)
43
+ end
44
+
45
+ # A fieldset of checkboxes, radios or switches with a legend, hint and error.
46
+ def lc_choice_group(legend:, hint: nil, error: nil, **attributes, &block)
47
+ lc_label!(legend, :legend)
48
+ errors = Array(error).compact_blank
49
+ tag.fieldset(**lc_attributes({ class: lc_field_class(extra: "m-0 min-w-0 border-0 p-0"), aria: { invalid: (true if errors.any?) }, data: { slot: "choice-group" } }, attributes)) do
50
+ safe_join([
51
+ tag.legend(legend, class: lc_field_label_class(extra: "p-0")),
52
+ tag.div(class: "flex flex-col gap-related", &block),
53
+ *lc_field_messages(nil, hint, errors)
54
+ ])
55
+ end
56
+ end
57
+
58
+ private
59
+ def lc_choice(slot, label, hint, card, name, unchecked_value, defaults, attributes, &block)
60
+ lc_label!(label, :label)
61
+ input = lc_attributes(defaults.merge(data: { slot: "#{slot}-input" }), attributes)
62
+ hidden = hidden_field_tag(name, unchecked_value, id: nil, disabled: input[:disabled]) if name && !unchecked_value.nil?
63
+ lc_choice_label(slot, label, hint, safe_join([ hidden, tag.input(**input) ].compact), card: card, &block)
64
+ end
65
+
66
+ # Shared with Lostcoords::Ui::FormBuilder, which renders its own inputs.
67
+ def lc_choice_label(slot, label, hint, input, card: false, **attributes, &block)
68
+ extra = capture(&block) if block
69
+ tag.label(**lc_attributes({ class: lc_choice_label_class(card: card), data: { slot: slot, card: (true if card) } }, attributes)) do
70
+ safe_join([
71
+ tag.span(input, class: "flex h-lh items-center"),
72
+ tag.span(class: "flex min-w-0 flex-1 flex-col gap-tight") do
73
+ safe_join([
74
+ tag.span(label, class: ("text-label" if card)),
75
+ (tag.span(hint, class: "text-small text-muted-foreground") if hint.present?),
76
+ (tag.span(extra, class: "flex min-w-0 flex-col pt-tight") if extra.present?)
77
+ ].compact)
78
+ end
79
+ ])
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ module Lostcoords::Ui
2
+ module CodeBlockHelper
3
+ def lc_code_block(source, id:, language: nil, title: nil, label: nil, max_lines: 10, **attributes, &block)
4
+ lc_label!(id, :id)
5
+ lc_label!(title, :title) unless title.nil?
6
+ label ||= lc_t("lostcoords.ui.code_block.label")
7
+ lc_label!(label, :label)
8
+ raise ArgumentError, "max_lines must be a positive integer or nil" unless max_lines.nil? || (max_lines.is_a?(Integer) && max_lines.positive?)
9
+
10
+ source = String.new(source.to_s)
11
+ content = block ? capture(source, &block) : source
12
+ truncated = !max_lines.nil? && source.lines.size > max_lines
13
+ defaults = {
14
+ id: id, class: "lc-code-block group/code-block relative min-h-15 overflow-hidden rounded-2xl bg-muted",
15
+ data: { controller: "lc-code-block", action: "lc-copy:error->lc-code-block#copyFailed" }
16
+ }
17
+ render "lostcoords/ui/components/lc_code_block", id: id, language: language, title: title, label: label,
18
+ content: content, truncated: truncated, max_lines: max_lines,
19
+ attributes: lc_attributes(defaults, attributes)
20
+ end
21
+ end
22
+ end
@@ -1,6 +1,6 @@
1
1
  module Lostcoords::Ui
2
2
  module CopyButtonHelper
3
- def lc_copy_button(label = nil, value: nil, source: nil, copied_label: nil, error_label: nil, reset_after: 2000, icon_only: false, variant: :secondary, size: :base, **attributes)
3
+ def lc_copy_button(label = nil, value: nil, source: nil, copied_label: nil, error_label: nil, reset_after: 2000, icon_only: false, variant: :secondary, size: :md, **attributes)
4
4
  raise ArgumentError, "Provide either value: or source:" unless value.nil? != source.nil?
5
5
  raise ArgumentError, "source: must be a nonblank element ID" if !source.nil? && source.blank?
6
6
  raise ArgumentError, "reset_after: must be a positive number of milliseconds" unless reset_after.is_a?(Numeric) && reset_after.finite? && reset_after.positive?
@@ -8,15 +8,15 @@ module Lostcoords::Ui
8
8
  label ||= lc_t("lostcoords.ui.copy_button.label")
9
9
  copied_label ||= lc_t("lostcoords.ui.copy_button.copied")
10
10
  error_label ||= lc_t("lostcoords.ui.copy_button.retry")
11
- size = lc_option!(:size, size, %i[sm base lg])
11
+ size = lc_option!(:size, size, %i[sm md lg])
12
12
  defaults = {
13
- class: lc_tw("inline-grid", ({ sm: "size-11 p-0", base: "size-12 p-0", lg: "size-14 p-0" }.fetch(size) if icon_only)),
14
- aria: { label: label }, data: { action: "ui-copy#copy", ui_copy_target: "button" }
13
+ class: lc_tw("inline-grid", ({ sm: "size-control-sm p-0", md: "size-control-md p-0", lg: "size-control-lg p-0" }.fetch(size) if icon_only)),
14
+ aria: { label: label }, data: { action: "lc-copy#copy", lc_copy_target: "button" }
15
15
  }
16
16
  button_attributes = lc_attributes(defaults, attributes).merge(type: "button")
17
17
  raise ArgumentError, "A copy button requires a nonblank accessible label" if button_attributes.dig(:aria, :label).blank?
18
18
 
19
- tag.span(class: "ui-copy", data: { controller: "ui-copy", state: "idle", ui_copy_value_value: value, ui_copy_source_value: source, ui_copy_reset_after_value: reset_after }) do
19
+ tag.span(class: "lc-copy", data: { controller: "lc-copy", state: "idle", lc_copy_value_value: value, lc_copy_source_value: source, lc_copy_reset_after_value: reset_after }) do
20
20
  safe_join([
21
21
  lc_button(variant: variant, size: size, **button_attributes) do
22
22
  safe_join([
@@ -25,14 +25,14 @@ module Lostcoords::Ui
25
25
  lc_copy_content(:error, "arrow-path", error_label, icon_only)
26
26
  ])
27
27
  end,
28
- tag.span(class: "sr-only", role: "status", aria: { atomic: true }, data: { ui_copy_target: "status", copied: copied_label, error: lc_t("lostcoords.ui.copy_button.error") })
28
+ tag.span(class: "sr-only", role: "status", aria: { atomic: true }, data: { lc_copy_target: "status", copied: copied_label, error: lc_t("lostcoords.ui.copy_button.error") })
29
29
  ])
30
30
  end
31
31
  end
32
32
 
33
33
  private
34
34
  def lc_copy_content(state, icon, label, icon_only)
35
- tag.span(class: "ui-copy-content", aria: { hidden: true }, data: { copy_state: state, icon: ("inline-start" unless icon_only) }) do
35
+ tag.span(class: "lc-copy-content", aria: { hidden: true }, data: { copy_state: state, icon: ("inline-start" unless icon_only) }) do
36
36
  safe_join([ lc_icon(icon, options: { "aria-hidden": true }), (tag.span(label) unless icon_only) ].compact)
37
37
  end
38
38
  end
@@ -0,0 +1,12 @@
1
+ module Lostcoords::Ui
2
+ module DescriptionListHelper
3
+ # Yields a builder; `list.item "Owner", "Ada"` or `list.item("Owner") { … }` adds a row.
4
+ # Rows stack on phones and sit in two columns from the sm breakpoint.
5
+ def lc_description_list(**attributes, &block)
6
+ defaults = { class: "m-0 min-w-0 divide-y divide-border [&>div:first-child]:pt-0 [&>div:last-child]:pb-0", data: { slot: "description-list" } }
7
+ tag.dl(**lc_attributes(defaults, attributes)) do
8
+ capture(Parts::DescriptionList.new(self), &block) if block
9
+ end
10
+ end
11
+ end
12
+ end