cafe_car 0.2.1 → 0.3.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.claude-plugin/marketplace.json +23 -0
  3. data/README.md +417 -29
  4. data/Rakefile +2 -2
  5. data/app/assets/stylesheets/cafe_car/chart.css +41 -0
  6. data/app/assets/stylesheets/cafe_car/dashboard.css +43 -0
  7. data/app/assets/stylesheets/cafe_car/filters.css +50 -0
  8. data/app/assets/stylesheets/cafe_car/themes/cool2.css +2 -0
  9. data/app/assets/stylesheets/cafe_car/themes/warm.css +30 -0
  10. data/app/assets/stylesheets/cafe_car/tooltips.css +1 -1
  11. data/app/assets/stylesheets/cafe_car.css +14 -4
  12. data/app/assets/stylesheets/tom-select.css +95 -0
  13. data/app/assets/stylesheets/ui/Button.css +7 -5
  14. data/app/assets/stylesheets/ui/Input.css +92 -1
  15. data/app/assets/stylesheets/ui/Page.css +7 -0
  16. data/app/assets/stylesheets/ui/Table.css +25 -0
  17. data/app/controllers/cafe_car/dashboards_controller.rb +29 -0
  18. data/app/javascript/cafe_car.js +68 -0
  19. data/app/javascript/tom-select.complete.min.js +444 -0
  20. data/app/policies/cafe_car/application_policy.rb +1 -1
  21. data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +4 -1
  22. data/app/presenters/cafe_car/presenter.rb +2 -2
  23. data/app/views/application/_actions.html.haml +9 -0
  24. data/app/views/application/_active_filters.html.haml +15 -0
  25. data/app/views/application/_belongs_to_filter.html.haml +6 -0
  26. data/app/views/application/_boolean_filter.html.haml +4 -0
  27. data/app/views/application/_bulk_actions.html.haml +9 -0
  28. data/app/views/application/_chart.html.haml +18 -0
  29. data/app/views/application/_controls.html.haml +8 -0
  30. data/app/views/application/_enum_filter.html.haml +5 -0
  31. data/app/views/application/_filter.html.haml +4 -0
  32. data/app/views/application/_filters.html.haml +13 -4
  33. data/app/views/application/_has_many_filter.html.haml +7 -0
  34. data/app/views/application/_head.html.haml +1 -0
  35. data/app/views/application/_index.html.haml +3 -1
  36. data/app/views/application/_index_actions.html.haml +12 -3
  37. data/app/views/application/_navigation.html.haml +5 -0
  38. data/app/views/application/_nested_field.html.haml +1 -1
  39. data/app/views/application/_range_filter.html.haml +8 -0
  40. data/app/views/application/_scope_filter.html.haml +5 -0
  41. data/app/views/application/_string_filter.html.haml +4 -0
  42. data/app/views/application/_table.html.haml +8 -6
  43. data/app/views/application/_text_filter.html.haml +1 -0
  44. data/app/views/cafe_car/application/index.html.haml +5 -2
  45. data/app/views/cafe_car/dashboard/_chart.html.haml +11 -0
  46. data/app/views/cafe_car/dashboard/_metric.html.haml +4 -0
  47. data/config/brakeman.ignore +0 -48
  48. data/config/importmap.rb +1 -0
  49. data/config/locales/en.yml +60 -0
  50. data/config/routes.rb +15 -2
  51. data/lib/cafe_car/attributes.rb +40 -12
  52. data/lib/cafe_car/chart_builder.rb +217 -0
  53. data/lib/cafe_car/controller/filtering.rb +108 -6
  54. data/lib/cafe_car/controller.rb +220 -11
  55. data/lib/cafe_car/engine.rb +2 -2
  56. data/lib/cafe_car/field_info.rb +14 -4
  57. data/lib/cafe_car/filter/field_info.rb +56 -7
  58. data/lib/cafe_car/filter/form_builder.rb +57 -7
  59. data/lib/cafe_car/filter_builder.rb +14 -1
  60. data/lib/cafe_car/form_builder.rb +61 -6
  61. data/lib/cafe_car/helpers.rb +196 -0
  62. data/lib/cafe_car/inputs/association_input.rb +11 -0
  63. data/lib/cafe_car/inputs/base_input.rb +56 -7
  64. data/lib/cafe_car/inputs/boolean_input.rb +9 -0
  65. data/lib/cafe_car/inputs/date_input.rb +8 -0
  66. data/lib/cafe_car/inputs/datetime_input.rb +8 -0
  67. data/lib/cafe_car/inputs/file_input.rb +11 -0
  68. data/lib/cafe_car/inputs/nested_input.rb +10 -0
  69. data/lib/cafe_car/inputs/number_input.rb +9 -0
  70. data/lib/cafe_car/inputs/password_input.rb +4 -2
  71. data/lib/cafe_car/inputs/rich_text_input.rb +9 -0
  72. data/lib/cafe_car/inputs/select_input.rb +10 -0
  73. data/lib/cafe_car/inputs/string_input.rb +3 -1
  74. data/lib/cafe_car/inputs/text_area_input.rb +9 -0
  75. data/lib/cafe_car/link_builder.rb +11 -0
  76. data/lib/cafe_car/model.rb +37 -8
  77. data/lib/cafe_car/navigation.rb +10 -0
  78. data/lib/cafe_car/policy.rb +85 -15
  79. data/lib/cafe_car/query_builder.rb +22 -0
  80. data/lib/cafe_car/routing.rb +26 -2
  81. data/lib/cafe_car/table/builder.rb +1 -1
  82. data/lib/cafe_car/table/foot_builder.rb +1 -0
  83. data/lib/cafe_car/table/head_builder.rb +6 -0
  84. data/lib/cafe_car/table/row_builder.rb +7 -0
  85. data/lib/cafe_car/version.rb +1 -1
  86. data/lib/cafe_car.rb +32 -2
  87. data/lib/generators/cafe_car/agents/USAGE +13 -0
  88. data/lib/generators/cafe_car/agents/agents_generator.rb +49 -0
  89. data/lib/generators/cafe_car/resource/resource_generator.rb +12 -1
  90. data/lib/tasks/demo.rake +9 -0
  91. data/llms.txt +25 -0
  92. data/skills/cafe_car/SKILL.md +135 -0
  93. data/skills/cafe_car/references/components.md +93 -0
  94. data/skills/cafe_car/references/controllers.md +106 -0
  95. data/skills/cafe_car/references/filtering.md +102 -0
  96. data/skills/cafe_car/references/forms.md +84 -0
  97. data/skills/cafe_car/references/locales.md +74 -0
  98. data/skills/cafe_car/references/navigation.md +67 -0
  99. data/skills/cafe_car/references/policies.md +112 -0
  100. data/skills/cafe_car/references/presenters.md +76 -0
  101. data/skills/cafe_car/references/turbo.md +53 -0
  102. data/skills/cafe_car/references/views.md +96 -0
  103. metadata +59 -16
  104. data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +0 -29
  105. data/app/views/application/_search.html.haml +0 -6
  106. data/lib/cafe_car/auto_resolver.rb +0 -49
  107. data/lib/cafe_car/inputs/association_builder.rb +0 -6
  108. data/lib/cafe_car/inputs/belongs_to_builder.rb +0 -6
  109. data/lib/tasks/holdco_tasks.rake +0 -532
  110. data/lib/tasks/templates/tasks_header.md +0 -37
@@ -0,0 +1,43 @@
1
+ @layer component {
2
+ .Dashboard {
3
+ display: grid;
4
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
5
+ gap: var(--gap);
6
+ align-items: start;
7
+ }
8
+
9
+ .Dashboard-tile {
10
+ padding: var(--gap);
11
+ border: 1px solid var(--border);
12
+ border-radius: var(--radius);
13
+ background: var(--card);
14
+ }
15
+
16
+ /* A chart spans wider than a metric tile so its bars have room to breathe. */
17
+ .Dashboard-chart {
18
+ grid-column: 1 / -1;
19
+ }
20
+
21
+ @media (min-width: 60rem) {
22
+ .Dashboard-chart {
23
+ grid-column: span 2;
24
+ }
25
+ }
26
+
27
+ .Dashboard-chartTitle {
28
+ margin: 0 0 var(--half-gap);
29
+ }
30
+
31
+ .Metric-value {
32
+ font-size: 2rem;
33
+ font-weight: 600;
34
+ line-height: 1.1;
35
+ color: var(--accent);
36
+ }
37
+
38
+ .Metric-label {
39
+ margin-block-start: var(--half-gap);
40
+ color: var(--dim);
41
+ font-size: 0.9rem;
42
+ }
43
+ }
@@ -0,0 +1,50 @@
1
+ @layer component {
2
+ /* The active-filter chip row (_active_filters). Scoped entirely under
3
+ .ActiveFilters — no bare element selectors — so it never restyles chips,
4
+ links, or buttons reused elsewhere. */
5
+ .ActiveFilters {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ gap: var(--half-gap);
10
+ margin-block-end: var(--gap);
11
+ }
12
+
13
+ .ActiveFilters-chip {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 0.4em;
17
+ padding: 0.15em 0.5em;
18
+ border: 1px solid var(--border);
19
+ border-radius: var(--control-radius);
20
+ background: var(--card);
21
+ font-size: 0.9rem;
22
+ line-height: 1.4;
23
+ }
24
+
25
+ .ActiveFilters-label {
26
+ color: var(--dim);
27
+ }
28
+
29
+ .ActiveFilters-label::after {
30
+ content: ":";
31
+ }
32
+
33
+ .ActiveFilters-value {
34
+ font-weight: 600;
35
+ }
36
+
37
+ .ActiveFilters-remove {
38
+ display: inline-flex;
39
+ color: var(--dim);
40
+ }
41
+
42
+ .ActiveFilters-remove:hover {
43
+ color: var(--color);
44
+ text-decoration: none;
45
+ }
46
+
47
+ .ActiveFilters-clear {
48
+ font-size: 0.9rem;
49
+ }
50
+ }
@@ -1,3 +1,5 @@
1
+ /* The `cool` palette with translucent cards/borders and a darker dark-mode
2
+ background — a distinct variant, selectable via `CafeCar.theme = :cool2`. */
1
3
  @import url(defaults.css);
2
4
 
3
5
  :root {
@@ -22,3 +22,33 @@
22
22
  body {
23
23
  scrollbar-color: rgb(209, 206, 203) rgb(246, 245, 243);
24
24
  }
25
+
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --blue: rgb(35, 175, 208);
29
+
30
+ --input: #1e1e1d;
31
+ --background: #191918;
32
+ --button: #333;
33
+
34
+ --color: #e7e3dc;
35
+ --dim: #6c7a91;
36
+
37
+ --card: #222221;
38
+ --border: #2A2A28;
39
+ --hover: rgb(42, 42, 40);
40
+ --shadow: rgba(0, 0, 0, 0.2);
41
+ }
42
+
43
+ body {
44
+ scrollbar-color: rgb(73, 72, 70) rgb(25, 25, 24);
45
+ }
46
+
47
+ .Card, .Table {
48
+ border: 0;
49
+ }
50
+
51
+ .Table_Head {
52
+ background-color: var(--card);
53
+ }
54
+ }
@@ -5,7 +5,7 @@
5
5
  display: block;
6
6
 
7
7
  position-anchor: auto;
8
- position-area: top;
8
+ position-area: bottom;
9
9
  position-try-fallbacks: flip-block;
10
10
 
11
11
  z-index: 99;
@@ -4,15 +4,22 @@
4
4
 
5
5
  @import url(trix.css) layer(vendor);
6
6
  @import url(iconoir.css) layer(vendor);
7
+ @import url(tom-select.css) layer(vendor);
7
8
  @import url(cafe_car/trix.css);
8
9
 
10
+ @import url(cafe_car/chart.css);
11
+ @import url(cafe_car/dashboard.css);
12
+ @import url(cafe_car/filters.css);
9
13
  @import url(cafe_car/pagination.css);
10
14
  @import url(cafe_car/tooltips.css);
11
15
  @import url(cafe_car/utility.css);
12
16
  @import url(ui/components.css);
13
17
  @import url(Lexend.css);
14
- @import url(cafe_car/themes/warm.css);
15
- @import url(cafe_car/themes/warm-dark.css) (prefers-color-scheme: dark);
18
+
19
+ /* The theme's custom properties are injected per-request in <head> by
20
+ CafeCar::Helpers#theme_stylesheet_tag, selected via `CafeCar.theme`
21
+ (default :warm). Don't @import a theme here — that would pin it and
22
+ defeat the config API. */
16
23
 
17
24
  @import url(cafe_car/code/base16-light.css);
18
25
  @import url(cafe_car/code/base16-dark.css) (prefers-color-scheme: dark);
@@ -29,13 +36,16 @@ a {
29
36
  color: var(--link);
30
37
  text-decoration: none;
31
38
 
39
+ /* No `filter` here: it would make the link a containing block for the
40
+ fixed tooltip pseudo, trapping it over the trigger. A slight color
41
+ shift gives equivalent hover/active feedback without one. */
32
42
  &:hover {
33
43
  text-decoration: underline;
34
- filter: brightness(1.05);
44
+ color: color-mix(in oklab, var(--link), white 12%);
35
45
  }
36
46
 
37
47
  &:active {
38
- filter: brightness(0.95);
48
+ color: color-mix(in oklab, var(--link), black 12%);
39
49
  }
40
50
  }
41
51
 
@@ -0,0 +1,95 @@
1
+ /*!
2
+ * Tom Select v2.4.3 — https://tom-select.js.org
3
+ * Licensed under the Apache License, Version 2.0.
4
+ * Vendored (default theme) for CafeCar; see config/importmap.rb for the JS pin.
5
+ */
6
+ .ts-control{border:1px solid #d0d0d0;border-radius:3px;box-shadow:inset 0 1px 1px rgba(0,0,0,.1);box-sizing:border-box;display:flex;flex-wrap:wrap;overflow:hidden;padding:8px;position:relative;width:100%;z-index:1}.ts-wrapper.multi.has-items .ts-control{padding:5px 8px 2px}.full .ts-control{background-color:#fff}.disabled .ts-control,.disabled .ts-control *{cursor:default!important}.focus .ts-control{box-shadow:inset 0 1px 2px rgba(0,0,0,.15)}.ts-control>*{display:inline-block;vertical-align:initial}.ts-wrapper.multi .ts-control>div{background:#1da7ee;border:1px solid #0073bb;color:#fff;cursor:pointer;margin:0 3px 3px 0;padding:2px 6px}.ts-wrapper.multi .ts-control>div.active{background:#92c836;border:1px solid #00578d;color:#fff}.ts-wrapper.multi.disabled .ts-control>div,.ts-wrapper.multi.disabled .ts-control>div.active{background:#d2d2d2;border:1px solid #aaa;color:#fff}.ts-control>input{background:none!important;border:0!important;box-shadow:none!important;display:inline-block!important;flex:1 1 auto;line-height:inherit!important;margin:0!important;max-height:none!important;max-width:100%!important;min-height:0!important;min-width:7rem;padding:0!important;text-indent:0!important;-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.ts-control>input::-ms-clear{display:none}.ts-control>input:focus{outline:none!important}.has-items .ts-control>input{margin:0 4px!important}.ts-control.rtl{text-align:right}.ts-control.rtl.single .ts-control:after{left:15px;right:auto}.ts-control.rtl .ts-control>input{margin:0 4px 0 -2px!important}.disabled .ts-control{background-color:#fafafa;opacity:.5}.input-hidden .ts-control>input{left:-10000px;opacity:0;position:absolute}.ts-dropdown{background:#fff;border:1px solid #d0d0d0;border-radius:0 0 3px 3px;border-top:0;box-shadow:0 1px 3px rgba(0,0,0,.1);box-sizing:border-box;left:0;margin:.25rem 0 0;position:absolute;top:100%;width:100%;z-index:10}.ts-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.ts-dropdown [data-selectable] .highlight{background:rgba(125,168,208,.2);border-radius:1px}.ts-dropdown .create,.ts-dropdown .no-results,.ts-dropdown .optgroup-header,.ts-dropdown .option{padding:5px 8px}.ts-dropdown .option,.ts-dropdown [data-disabled],.ts-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.ts-dropdown [data-selectable].option{cursor:pointer;opacity:1}.ts-dropdown .optgroup:first-child .optgroup-header{border-top:0}.ts-dropdown .optgroup-header{background:#fff;color:#303030;cursor:default}.ts-dropdown .active{background-color:#f5fafd;color:#495c68}.ts-dropdown .active.create{color:#495c68}.ts-dropdown .create{color:rgba(48,48,48,.5)}.ts-dropdown .spinner{display:inline-block;height:30px;margin:5px 8px;width:30px}.ts-dropdown .spinner:after{animation:lds-dual-ring 1.2s linear infinite;border-color:#d0d0d0 transparent;border-radius:50%;border-style:solid;border-width:5px;content:" ";display:block;height:24px;margin:3px;width:24px}@keyframes lds-dual-ring{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.ts-dropdown-content{max-height:200px;overflow:hidden auto;scroll-behavior:smooth}.ts-wrapper.plugin-drag_drop .ts-dragging{color:transparent!important}.ts-wrapper.plugin-drag_drop .ts-dragging>*{visibility:hidden!important}.plugin-checkbox_options:not(.rtl) .option input{margin-right:.5rem}.plugin-checkbox_options.rtl .option input{margin-left:.5rem}.plugin-clear_button{--ts-pr-clear-button:1em}.plugin-clear_button .clear-button{background:transparent!important;cursor:pointer;margin-right:0!important;opacity:0;position:absolute;right:2px;top:50%;transform:translateY(-50%);transition:opacity .5s}.plugin-clear_button.form-select .clear-button,.plugin-clear_button.single .clear-button{right:max(var(--ts-pr-caret),8px)}.plugin-clear_button.focus.has-items .clear-button,.plugin-clear_button:not(.disabled):hover.has-items .clear-button{opacity:1}.ts-wrapper .dropdown-header{background:color-mix(#fff,#d0d0d0,85%);border-bottom:1px solid #d0d0d0;border-radius:3px 3px 0 0;padding:10px 8px;position:relative}.ts-wrapper .dropdown-header-close{color:#303030;font-size:20px!important;line-height:20px;margin-top:-12px;opacity:.4;position:absolute;right:8px;top:50%}.ts-wrapper .dropdown-header-close:hover{color:#000}.plugin-dropdown_input.focus.dropdown-active .ts-control{border:1px solid #d0d0d0;box-shadow:none}.plugin-dropdown_input .dropdown-input{background:transparent;border:solid #d0d0d0;border-width:0 0 1px;box-shadow:inset 0 1px 1px rgba(0,0,0,.1);display:block;padding:8px;width:100%}.plugin-dropdown_input .items-placeholder{border:0!important;box-shadow:none!important;width:100%}.plugin-dropdown_input.dropdown-active .items-placeholder,.plugin-dropdown_input.has-items .items-placeholder{display:none!important}.ts-wrapper.plugin-input_autogrow.has-items .ts-control>input{min-width:0}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input{flex:none;min-width:4px}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::-ms-input-placeholder{color:transparent}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::placeholder{color:transparent}.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content{display:flex}.ts-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0;flex-basis:0;flex-grow:1;min-width:0}.ts-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0}.ts-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.ts-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0}.ts-wrapper.plugin-remove_button .item{align-items:center;display:inline-flex}.ts-wrapper.plugin-remove_button .item .remove{border-radius:0 2px 2px 0;box-sizing:border-box;color:inherit;display:inline-block;padding:0 6px;text-decoration:none;vertical-align:middle}.ts-wrapper.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,.05)}.ts-wrapper.plugin-remove_button.disabled .item .remove:hover{background:none}.ts-wrapper.plugin-remove_button .remove-single{font-size:23px;position:absolute;right:0;top:0}.ts-wrapper.plugin-remove_button:not(.rtl) .item{padding-right:0!important}.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove{border-left:1px solid #0073bb;margin-left:6px}.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove{border-left-color:#00578d}.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove{border-left-color:#aaa}.ts-wrapper.plugin-remove_button.rtl .item{padding-left:0!important}.ts-wrapper.plugin-remove_button.rtl .item .remove{border-right:1px solid #0073bb;margin-right:6px}.ts-wrapper.plugin-remove_button.rtl .item.active .remove{border-right-color:#00578d}.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove{border-right-color:#aaa}:root{--ts-pr-clear-button:0px;--ts-pr-caret:0px;--ts-pr-min:.75rem}.ts-wrapper.single .ts-control,.ts-wrapper.single .ts-control input{cursor:pointer}.ts-control:not(.rtl){padding-right:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-control.rtl{padding-left:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-wrapper{position:relative}.ts-control,.ts-control input,.ts-dropdown{color:#303030;font-family:inherit;font-size:13px;line-height:18px}.ts-control,.ts-wrapper.single.input-active .ts-control{background:#fff;cursor:text}.ts-hidden-accessible{border:0!important;clip:rect(0 0 0 0)!important;-webkit-clip-path:inset(50%)!important;clip-path:inset(50%)!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important}.ts-wrapper.single .ts-control{--ts-pr-caret:2rem}.ts-wrapper.single .ts-control:after{border-color:grey transparent transparent;border-style:solid;border-width:5px 5px 0;content:" ";display:block;height:0;margin-top:-3px;position:absolute;top:50%;width:0}.ts-wrapper.single .ts-control:not(.rtl):after{right:15px}.ts-wrapper.single .ts-control.rtl:after{left:15px}.ts-wrapper.single.dropdown-active .ts-control:after{border-color:transparent transparent grey;border-width:0 5px 5px;margin-top:-4px}.ts-wrapper.single.input-active .ts-control,.ts-wrapper.single.input-active .ts-control input{cursor:text}.ts-wrapper{display:flex;min-height:36px}.ts-wrapper.multi.has-items .ts-control{padding-left:5px;--ts-pr-min:$padding-x}.ts-wrapper.multi .ts-control [data-value]{background-color:color-mix(#1da7ee,#178ee9,60%);background-image:linear-gradient(180deg,#1da7ee,#178ee9);background-repeat:repeat-x;border-radius:3px;box-shadow:0 1px 0 rgba(0,0,0,.2),inset 0 1px hsla(0,0%,100%,.03);text-shadow:0 1px 0 rgba(0,51,83,.3)}.ts-wrapper.multi .ts-control [data-value].active{background-color:color-mix(#008fd8,#0075cf,60%);background-image:linear-gradient(180deg,#008fd8,#0075cf);background-repeat:repeat-x}.ts-wrapper.multi.disabled .ts-control [data-value]{background:none;box-shadow:none;color:#999;text-shadow:none}.ts-wrapper.multi.disabled .ts-control [data-value],.ts-wrapper.multi.disabled .ts-control [data-value] .remove{border-color:#e6e6e6}.ts-wrapper.multi.disabled .ts-control [data-value] .remove{background:none}.ts-wrapper.single .ts-control{background-color:color-mix(#fefefe,#f2f2f2,60%);background-image:linear-gradient(180deg,#fefefe,#f2f2f2);background-repeat:repeat-x;box-shadow:0 1px 0 rgba(0,0,0,.05),inset 0 1px 0 hsla(0,0%,100%,.8)}.ts-dropdown.single,.ts-wrapper.single .ts-control{border-color:#b8b8b8}.dropdown-active .ts-control{border-radius:3px 3px 0 0}.ts-dropdown .optgroup-header{font-size:.85em;font-weight:700;padding-top:7px}.ts-dropdown .optgroup{border-top:1px solid #f0f0f0}.ts-dropdown .optgroup:first-child{border-top:0}
7
+ /*# sourceMappingURL=tom-select.default.min.css.map */
8
+
9
+ /* CafeCar theming — bind the vendored default theme to the app's CSS custom
10
+ properties so the association typeahead reads like every other input and
11
+ follows light/dark mode (see cafe_car/themes/*.css). No hardcoded colors:
12
+ the tokens carry the light/dark values, keeping styling inside the component
13
+ per CafeCar's "no styles outside of components." Selectors mirror the
14
+ vendored ones so specificity ties and source order (this block is last) wins. */
15
+ .ts-wrapper {
16
+ color: inherit;
17
+ }
18
+
19
+ /* The control matches a text input (see ui/Input.css): token background/border,
20
+ the same radius and soft shadow, no baked-in gradient. */
21
+ .ts-control,
22
+ .full .ts-control,
23
+ .ts-wrapper.single .ts-control,
24
+ .ts-wrapper.single.input-active .ts-control {
25
+ background: var(--input);
26
+ background-image: none;
27
+ border: 1px solid var(--border);
28
+ border-radius: var(--control-radius);
29
+ box-shadow: rgba(34, 32, 29, 0.05) 0 1px 2px;
30
+ color: inherit;
31
+ }
32
+
33
+ .ts-control,
34
+ .ts-control input,
35
+ .ts-dropdown {
36
+ color: inherit;
37
+ }
38
+
39
+ .ts-control input::placeholder {
40
+ color: inherit;
41
+ opacity: 0.3;
42
+ }
43
+
44
+ .focus .ts-control {
45
+ outline: 2px solid var(--outline);
46
+ outline-offset: -1px;
47
+ box-shadow: none;
48
+ }
49
+
50
+ /* Multi-select chips read as filled accent controls. */
51
+ .ts-wrapper.multi .ts-control > div,
52
+ .ts-wrapper.multi .ts-control [data-value] {
53
+ background: var(--accent);
54
+ background-image: none;
55
+ border: 1px solid var(--accent);
56
+ border-radius: var(--control-radius);
57
+ box-shadow: none;
58
+ color: var(--white);
59
+ text-shadow: none;
60
+ }
61
+
62
+ .ts-wrapper.multi .ts-control > div.active,
63
+ .ts-wrapper.multi .ts-control [data-value].active {
64
+ background: var(--accent);
65
+ background-image: none;
66
+ border-color: var(--accent);
67
+ }
68
+
69
+ /* The dropdown is a menu surface bound to the card/border tokens. */
70
+ .ts-dropdown {
71
+ background: var(--card);
72
+ border: 1px solid var(--border);
73
+ border-radius: var(--control-radius);
74
+ box-shadow: var(--box-shadow);
75
+ }
76
+
77
+ .ts-dropdown .option,
78
+ .ts-dropdown .create,
79
+ .ts-dropdown .no-results,
80
+ .ts-dropdown .optgroup-header {
81
+ color: inherit;
82
+ }
83
+
84
+ .ts-dropdown .optgroup-header {
85
+ background: var(--card);
86
+ }
87
+
88
+ .ts-dropdown .active {
89
+ background: var(--selection);
90
+ color: inherit;
91
+ }
92
+
93
+ .ts-dropdown [data-selectable] .highlight {
94
+ background: var(--selection);
95
+ }
@@ -9,7 +9,7 @@
9
9
  padding: var(--half-gap);
10
10
  box-shadow: inset var(--darken) 0 -1px 0 1px;
11
11
  border: 0;
12
- display: flex;
12
+ display: inline-flex;
13
13
  align-items: center;
14
14
  gap: 0.5em;
15
15
  user-select: none;
@@ -18,17 +18,19 @@
18
18
  .Button:where(:not(.Button-current)):hover {
19
19
  cursor: pointer;
20
20
  text-decoration: none;
21
- box-shadow: inset var(--darken) 0 -2px 0 1px;
22
- filter: brightness(1.05);
21
+ /* A translucent lighten overlay stands in for filter: brightness(1.05).
22
+ A `filter` would make this a containing block for the fixed tooltip
23
+ pseudo, trapping it over the trigger — box-shadow does not. */
24
+ box-shadow: inset var(--darken) 0 -2px 0 1px, inset var(--lighten) 0 0 0 100vmax;
23
25
  z-index: 1;
24
26
  }
25
27
 
26
28
  .Button:active,
27
29
  .Button-current {
28
30
  text-decoration: none;
29
- filter: brightness(0.95);
31
+ /* Darken overlay in place of filter: brightness(0.95) — see hover note. */
30
32
  border-bottom-width: 1px;
31
- box-shadow: inset var(--darken) 0 1px;
33
+ box-shadow: inset var(--darken) 0 1px, inset var(--darken) 0 0 0 100vmax;
32
34
  }
33
35
 
34
36
  .Button-primary {
@@ -1,5 +1,5 @@
1
1
  .Input,
2
- input,
2
+ input:not([type="checkbox"], [type="radio"]),
3
3
  textarea,
4
4
  select {
5
5
  background: var(--input);
@@ -10,6 +10,97 @@ select {
10
10
  box-shadow: rgba(34, 32, 29, 0.05) 0px 1px 2px;
11
11
  }
12
12
 
13
+ /* Checkboxes and radios: native inputs restyled to the theme so they read as
14
+ CafeCar controls, not raw browser widgets. `appearance: none` drops the OS
15
+ chrome while keeping the element a real, keyboard-operable input — focus,
16
+ Space/Enter, and label association all stay native. The mark is drawn with a
17
+ ::before so no extra markup or image is needed.
18
+
19
+ SCOPED to the components that own these controls — form fields (.Field) and the
20
+ table's select column (.Table_Cell-select). Left global, an `input[type=checkbox]`
21
+ rule outweighs (specificity 0,1,1) the class-based styling of checkboxes reused for
22
+ other purposes — e.g. the sidebar's .Layout_Menu toggle — and clobbers them. Styling
23
+ lives inside the component, per "no styles outside of components." */
24
+ :where(.Field, .Table_Cell-select) {
25
+ input[type="checkbox"],
26
+ input[type="radio"] {
27
+ appearance: none;
28
+ -webkit-appearance: none;
29
+ flex: none;
30
+ display: inline-grid;
31
+ place-content: center;
32
+ width: 1.15em;
33
+ height: 1.15em;
34
+ margin: 0;
35
+ padding: 0;
36
+ color: inherit;
37
+ background: var(--input);
38
+ border: 1px solid var(--border);
39
+ border-radius: var(--control-radius);
40
+ box-shadow: rgba(34, 32, 29, 0.05) 0px 1px 2px;
41
+ cursor: pointer;
42
+ vertical-align: -0.2em;
43
+ transition: background var(--duration), border-color var(--duration);
44
+ }
45
+
46
+ input[type="radio"] {
47
+ border-radius: 50%;
48
+ }
49
+
50
+ input[type="checkbox"]::before,
51
+ input[type="radio"]::before {
52
+ content: "";
53
+ width: 0.68em;
54
+ height: 0.68em;
55
+ transform: scale(0);
56
+ transition: transform var(--duration) ease-in-out;
57
+ background: var(--white);
58
+ }
59
+
60
+ input[type="checkbox"]::before {
61
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
62
+ }
63
+
64
+ input[type="radio"]::before {
65
+ border-radius: 50%;
66
+ width: 0.55em;
67
+ height: 0.55em;
68
+ }
69
+
70
+ input[type="checkbox"]:checked,
71
+ input[type="radio"]:checked {
72
+ background: var(--accent);
73
+ border-color: var(--accent);
74
+ }
75
+
76
+ input[type="checkbox"]:checked::before,
77
+ input[type="radio"]:checked::before {
78
+ transform: scale(1);
79
+ }
80
+
81
+ input[type="checkbox"]:indeterminate {
82
+ background: var(--accent);
83
+ border-color: var(--accent);
84
+ }
85
+
86
+ input[type="checkbox"]:indeterminate::before {
87
+ clip-path: inset(45% 15%);
88
+ transform: scale(1);
89
+ }
90
+
91
+ input[type="checkbox"]:focus-visible,
92
+ input[type="radio"]:focus-visible {
93
+ outline: 2px solid var(--outline);
94
+ outline-offset: 2px;
95
+ }
96
+
97
+ input[type="checkbox"]:disabled,
98
+ input[type="radio"]:disabled {
99
+ cursor: not-allowed;
100
+ opacity: 0.5;
101
+ }
102
+ }
103
+
13
104
  input,
14
105
  textarea,
15
106
  select {
@@ -79,6 +79,13 @@
79
79
  /* overflow: auto; */
80
80
  }
81
81
 
82
+ /* The body is a stretch column, so cards/tables/forms fill its width. A lone
83
+ button is an action, not a block — size it to its label and align it left
84
+ instead of letting it span the whole page. */
85
+ .Page_Body > .Button {
86
+ align-self: start;
87
+ }
88
+
82
89
  .Page-slim .Page_Body,
83
90
  .Page_Body-slim {
84
91
  max-width: 720px;
@@ -98,4 +98,29 @@
98
98
  &-controls {
99
99
  text-align: right;
100
100
  }
101
+
102
+ &-select {
103
+ text-align: center;
104
+ padding-inline: 0.5em;
105
+ }
106
+ }
107
+
108
+ /* Index toolbar: the row above the table holding the bulk-action bar. Wraps
109
+ on narrow screens so the controls don't get crushed. */
110
+ .IndexToolbar {
111
+ display: flex;
112
+ flex-wrap: wrap;
113
+ align-items: center;
114
+ gap: var(--half-gap) var(--gap);
115
+ margin-bottom: var(--gap);
116
+ }
117
+
118
+ .BulkActions {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ gap: var(--half-gap);
122
+ }
123
+
124
+ .BulkActions[hidden] {
125
+ display: none;
101
126
  }
@@ -0,0 +1,29 @@
1
+ module CafeCar
2
+ # The opt-in dashboard overview. A host defines the dashboard by writing one
3
+ # template — `app/views/cafe_car/dashboard/show.html.haml` — that composes the
4
+ # `metric`/`chart` helpers (and the policy-driven `metrics` helper). Its existence
5
+ # IS the opt-in: the route always mounts, but with no host template this 404s, so
6
+ # a CRUD-only host never inherits a blank page. It has no model of its own, so it
7
+ # skips the CRUD policy/authorization pipeline like the components gallery does.
8
+ class DashboardsController < const(:ApplicationController)
9
+ include Controller
10
+ helper CafeCar::Helpers
11
+
12
+ before_action :skip_policy_scope
13
+ before_action :skip_authorization
14
+
15
+ def show
16
+ return head(:not_found) unless dashboard_template?
17
+ render "cafe_car/dashboard/show"
18
+ end
19
+
20
+ private
21
+
22
+ # Whether the host has written the dashboard template — the opt-in signal. The
23
+ # engine ships no `cafe_car/dashboard/show`, so this is true only when the host
24
+ # placed one at `app/views/cafe_car/dashboard/show`.
25
+ def dashboard_template? = template_exists?("show", %w[cafe_car/dashboard], false)
26
+
27
+ def model_name = @model_name ||= ActiveModel::Name.new(:dashboard)
28
+ end
29
+ end
@@ -1,4 +1,42 @@
1
1
  import "@hotwired/turbo-rails"
2
+ import "tom-select" // decorates [data-searchable-select] selects; sets globalThis.TomSelect
3
+
4
+ // Searchable association selects. Tom Select DECORATES an existing <select>, so the
5
+ // field keeps working as a plain select if this never runs (progressive enhancement).
6
+ // A `data-searchable-select-url` points at the model's JSON typeahead feed, letting
7
+ // the field reach records past `CafeCar.max_collection_options`; without it the select
8
+ // falls back to local search over the rendered options.
9
+ function enhanceSelects() {
10
+ document.querySelectorAll("select[data-searchable-select]").forEach(el => {
11
+ if (el.tomselect) return // Tom Select errors on an already-enhanced element
12
+
13
+ let url = el.dataset.searchableSelectUrl
14
+ let config = { maxOptions: null }
15
+
16
+ if (url) Object.assign(config, {
17
+ valueField: "value",
18
+ labelField: "text",
19
+ searchField: ["text"],
20
+ load(query, callback) {
21
+ fetch(`${url}?q=${encodeURIComponent(query)}`, { headers: { Accept: "application/json" } })
22
+ .then(response => response.json())
23
+ .then(callback)
24
+ .catch(() => callback())
25
+ }
26
+ })
27
+
28
+ new TomSelect(el, config)
29
+ })
30
+ }
31
+
32
+ addEventListener("turbo:load", enhanceSelects)
33
+ addEventListener("turbo:frame-render", enhanceSelects)
34
+
35
+ // Revert enhancement before Turbo snapshots the page, so a restored cache doesn't
36
+ // carry a duplicated (already-decorated) widget.
37
+ addEventListener("turbo:before-cache", () => {
38
+ document.querySelectorAll("select[data-searchable-select]").forEach(el => el.tomselect?.destroy())
39
+ })
2
40
 
3
41
  Object.assign(Turbo.StreamActions, {
4
42
  // <turbo-stream action="navigate" target="Hello, world"></turbo-stream>
@@ -61,6 +99,36 @@ addEventListener("click", event => {
61
99
  }
62
100
  })
63
101
 
102
+ // Bulk-select: the header checkbox toggles every row checkbox in its form, and
103
+ // the bulk-action bar stays hidden until at least one row is selected. The bar
104
+ // lives in the index toolbar (outside the table's form), so visibility keys off
105
+ // any checked `ids[]` box on the page, and the header box shows an indeterminate
106
+ // state when only some rows are picked.
107
+ function refreshBulkBar() {
108
+ let boxes = document.querySelectorAll("input[name='ids[]']")
109
+ let checked = [...boxes].filter(box => box.checked)
110
+ let selected = checked.length > 0
111
+
112
+ document.querySelectorAll("[data-bulk-bar]").forEach(bar => bar.hidden = !selected)
113
+ document.querySelectorAll("[data-bulk-select-all]").forEach(all => {
114
+ all.checked = selected && checked.length === boxes.length
115
+ all.indeterminate = selected && checked.length < boxes.length
116
+ })
117
+ }
118
+
119
+ addEventListener("change", event => {
120
+ let all = event.target.closest("[data-bulk-select-all]")
121
+ if (all) {
122
+ let form = all.closest("form")
123
+ form.querySelectorAll("input[name='ids[]']").forEach(box => box.checked = all.checked)
124
+ refreshBulkBar()
125
+ } else if (event.target.matches("input[name='ids[]']")) {
126
+ refreshBulkBar()
127
+ }
128
+ })
129
+
130
+ addEventListener("turbo:load", refreshBulkBar)
131
+
64
132
  function animationEnd({ target }) {
65
133
  if (target.matches(".remove")) target.remove()
66
134
  else if (target.matches(".popup")) {