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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f2ee78455723f808d6f02bbcad83ac86cd5f4fcf8c05203bd3afc602afe1d4f
4
- data.tar.gz: '09447696c9bd504e776cc1cc493025ff99ceb467276ca916eb660165bfdf42cc'
3
+ metadata.gz: 8782de0874fe886849232cebef1d036e0cc43680e758a5bfdc2a0b027e26b06c
4
+ data.tar.gz: 346735b4f4568e3c28a751e8dde44d5e85fa0b5d907c2ce95e0e004ff4277e1e
5
5
  SHA512:
6
- metadata.gz: b39a51b42de3e8ffa3440e3d714eab15415592e30077187ddabf970826f64ef210d7f4f3e6a61ef28de43e0c8715dbaba35367700d1f2537fad3817c16b347ee
7
- data.tar.gz: 30792c4c93baf653babd0f2bb64dc0790875faa41db6ef7d5dcf93e69238c146752930794a2672f68eee83c15facfb537b200825750b370d2199e79dc627cc2e
6
+ metadata.gz: 84003696580e71078eced5706bf2a4d33858de2aee2a2b8ac3c2764838f53226d6fa5c1b6015a9bec4ec5f8049ef7cbd3188db462ad6500f29e79c6ff5b9778a
7
+ data.tar.gz: 4ec99470b492701f531e2977d0fd88cc2f33fa3b4b35327b25a3b5b11f233b75fb91a5b2fdad3ff72a5398975c130cc8b6ffca4db5e159ebc6cfdbcd8c8c036b
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://www.schemastore.org/claude-code-marketplace.json",
3
+ "name": "cafe-car",
4
+ "description": "CafeCar — a composable view extension for Rails",
5
+ "owner": {
6
+ "name": "Jeff Peterson",
7
+ "email": "jeff@yak.sh"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "cafe-car",
12
+ "source": "./",
13
+ "strict": false,
14
+ "description": "The CafeCar Agent Skill: point a controller at a model and the Pundit policy drives the whole UI — index, show, new, edit, filtering, sorting, CSV export, and Turbo-morph live updates. Teaches agents to extend CafeCar's defaults instead of hand-rolling admin views.",
15
+ "author": {
16
+ "name": "Jeff Peterson"
17
+ },
18
+ "license": "MIT",
19
+ "homepage": "https://github.com/craft-concept/cafe_car",
20
+ "skills": ["./skills/cafe_car"]
21
+ }
22
+ ]
23
+ }
data/README.md CHANGED
@@ -4,38 +4,76 @@
4
4
  CafeCar
5
5
  </h1>
6
6
 
7
+ <p><em>🚋 Recline in the cafe car while Rails renders your views for you.</em></p>
8
+
7
9
  [![CI](https://img.shields.io/github/actions/workflow/status/craft-concept/cafe_car/ci.yml?branch=main&label=CI)](https://github.com/craft-concept/cafe_car/actions/workflows/ci.yml)
8
10
  [![Gem Version](https://img.shields.io/gem/v/cafe_car)](https://rubygems.org/gems/cafe_car)
9
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT)
10
12
 
11
- > 🚀 **[Live demo →](https://cafe-car-demo-production.up.railway.app)** — click straight
12
- > into a real auto-generated admin (clients, invoices, articles, users, notes). No signup;
13
- > the data resets periodically.
13
+ > 🚀 **[Live demo →](https://cafe-car-demo.up.railway.app)** — click straight
14
+ > into a real admin, rendered from plain models (clients, invoices, articles, users, notes).
15
+ > No signup; the data resets periodically.
14
16
 
15
17
  <p align="center">
16
- <a href="https://cafe-car-demo-production.up.railway.app/admin/invoices">
18
+ <a href="https://cafe-car-demo.up.railway.app/admin/invoices">
17
19
  <img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoices-index.png"
18
- alt="CafeCar's auto-generated admin: an invoices index with sortable columns, currency formatting, association links, sender avatars, and pagination — all rendered straight from the model with no view code."
20
+ alt="A CafeCar admin rendered straight from the model: an invoices index with sortable columns, currency formatting, association links, sender avatars, and pagination — no view code."
19
21
  width="900">
20
22
  </a>
21
23
  </p>
22
24
  <p align="center">
23
25
  <em>A complete admin index — sortable columns, formatted values, association links, and
24
- pagination — auto-generated from a model with one line of controller code.
25
- <a href="https://cafe-car-demo-production.up.railway.app">Try the live demo →</a></em>
26
+ pagination — rendered from a model with one line of controller code.
27
+ <a href="https://cafe-car-demo.up.railway.app">Try the live demo →</a></em>
26
28
  </p>
27
29
 
28
- CafeCar is a Rails engine that extends the MVC "view" layer to provide automatic
29
- CRUD UI generation with sensible defaults. Its philosophy is rooted in the idea
30
- that Rails should render _something_ that represents the CRUD operations of your
31
- models by default. These defaults can then be expanded or overridden on either
32
- an application-wide or model-specific basis.
30
+ Your model already knows its columns, types, and associations a full
31
+ description of a resource. Rails still makes you hand-write a controller, seven
32
+ actions, and a folder of view templates before any of it renders in a browser.
33
+ CafeCar closes that gap. It's a composable view extension for Rails an
34
+ extension of the view and controller layer that renders index, show, new, and
35
+ edit straight from the model, with Pundit authorization, filtering, and Hotwire,
36
+ from one line of controller code. It's how Rails ought to work out of the box:
37
+ render something for your models by default, then get out of the way. Every
38
+ default is a starting point — override any view, presenter, or policy with
39
+ ordinary Rails when the default is wrong.
40
+
41
+ **Perfect for**: Rails developers who need a working admin this week — not a
42
+ second framework to learn and configure.
43
+
44
+ ## Try it in 60 seconds
45
+
46
+ ```bash
47
+ # 1. Install the gem and run the installer
48
+ bundle add cafe_car
49
+ bin/rails generate cafe_car:install
50
+ ```
51
+
52
+ ```ruby
53
+ # 2. Point a controller at a model (add `resources :products` to your routes)
54
+ class ProductsController < ApplicationController
55
+ cafe_car
56
+ end
57
+ ```
58
+
59
+ ```ruby
60
+ # 3. Say who can do what — and which fields are editable
61
+ class ProductPolicy < ApplicationPolicy
62
+ def index? = user.present?
63
+ def update? = user.admin?
64
+
65
+ def permitted_attributes = %i[name price description]
66
+ end
67
+ ```
33
68
 
34
- **Perfect for**: Admin panels, internal tools, and rapid prototyping.
69
+ Visit `/products`: index, show, new, and edit, all generated from the model.
70
+ When a default is wrong, override that one piece — see
71
+ [Getting Started](#getting-started).
35
72
 
36
73
  ## Table of Contents
37
74
 
38
75
  - [How CafeCar compares](#how-cafecar-compares)
76
+ - [How CafeCar relates to ViewComponent & Phlex](#how-cafecar-relates-to-viewcomponent--phlex)
39
77
  - [Features](#features)
40
78
  - [Prerequisites](#prerequisites)
41
79
  - [Installation](#installation)
@@ -55,6 +93,7 @@ an application-wide or model-specific basis.
55
93
  - [Policy Generator](#policy-generator)
56
94
  - [Notes Generator](#notes-generator)
57
95
  - [Sessions Generator](#sessions-generator)
96
+ - [Using CafeCar with AI coding agents](#using-cafecar-with-ai-coding-agents)
58
97
  - [Configuration](#configuration)
59
98
  - [Testing](#testing)
60
99
  - [Contributing](#contributing)
@@ -82,10 +121,27 @@ Reach for one of them when its model matches how you want to work:
82
121
  Reach for CafeCar when you want a Rails-native, convention-over-configuration
83
122
  admin that you extend with ordinary Rails code.
84
123
 
124
+ ## How CafeCar relates to ViewComponent & Phlex
125
+
126
+ ViewComponent and Phlex answer "what's the unit of reuse for the UI I write?"
127
+ CafeCar answers a different question: "why hand-write the CRUD index, show, and
128
+ form views at all?" They sit at different layers, so they compose rather than
129
+ compete.
130
+
131
+ CafeCar renders index, show, new, and edit straight from the model — the
132
+ boilerplate you'd otherwise write once per resource and maintain forever. Keep
133
+ your ViewComponent or Phlex components for the screens you customize, and drop
134
+ them into a CafeCar view or presenter like any other partial. CafeCar covers the
135
+ boilerplate screens; your component library covers the parts worth building by hand.
136
+
137
+ Plain partials work the same way. Pick whichever view primitive you like —
138
+ CafeCar is the convention layer above it, deleting the boilerplate views so you
139
+ write only the ones that earn their keep.
140
+
85
141
  ## Features
86
142
 
87
- - 🚀 **Auto-generated CRUD interfaces** - One line of code generates complete
88
- index, show, new, edit views
143
+ - 🚀 **Index, show, new, edit from the model** - One line of controller code
144
+ renders all four, straight from your model
89
145
  - 🎨 **Component-based UI system** - Flexible, composable components for
90
146
  building interfaces
91
147
  - 🔐 **Built-in authorization** - Pundit integration for attribute-level
@@ -95,11 +151,19 @@ admin that you extend with ordinary Rails code.
95
151
  association filters
96
152
  - 🔎 **Keyword search** - Turnkey search box on every index, matching across a
97
153
  model's text columns with zero per-model setup
154
+ - 📈 **Chart view** - A third index view (beside grid/table) that buckets records
155
+ over any date column and plots the count, or a sum/average of a numeric column,
156
+ as dependency-free inline SVG
157
+ - 📊 **Dashboard** - An opt-in overview page composing metric tiles and charts;
158
+ you write one view to turn it on, off by default
98
159
  - ⬇️ **CSV export** - One-click "Download CSV" of the current filtered, sorted
99
160
  view on any index
161
+ - ☑️ **Bulk actions** - Select rows and act on many at once (delete ships built
162
+ in); every selected record is authorized against your policy on its own
100
163
  - 📄 **Pagination & sorting** - Kaminari integration with sortable columns
101
164
  - ⚡ **Hotwire ready** - Turbo Streams support out of the box
102
- - 📝 **Intelligent forms** - Auto-generated forms with smart field detection
165
+ - 📝 **Intelligent forms** - Forms rendered from your schema, with smart field
166
+ detection
103
167
 
104
168
  ## Prerequisites
105
169
 
@@ -223,6 +287,8 @@ end
223
287
  - **Callbacks**: Lifecycle hooks for `render`, `update`, `create`, `destroy`
224
288
  - **Responders**: JSON, HTML, and Turbo Stream responses
225
289
 
290
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/clients) — the clients index from that one line.
291
+
226
292
  **Limiting actions:**
227
293
 
228
294
  ```ruby
@@ -280,7 +346,7 @@ end
280
346
  **Key methods:**
281
347
 
282
348
  - `permitted_attributes` - Attributes that can be edited via forms
283
- - `displayable_attributes` - Attributes shown in views (auto-detected from
349
+ - `attributes.displayable` - Attributes shown in views (auto-detected from
284
350
  columns + associations)
285
351
  - `displayable_associations` - Associations that can be displayed
286
352
  - `filtered_attribute?(attr)` - Check if attribute should be hidden (uses Rails
@@ -419,15 +485,15 @@ end
419
485
  CafeCar provides an enhanced form builder with smart field detection.
420
486
 
421
487
  <p align="center">
422
- <a href="https://cafe-car-demo-production.up.railway.app/admin/invoices/new">
488
+ <a href="https://cafe-car-demo.up.railway.app/admin/invoices/new">
423
489
  <img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoice-form.png"
424
- alt="CafeCar's auto-generated new-invoice form with a client association select, typed inputs, and nested has_many line items (add/remove rows)."
490
+ alt="A CafeCar new-invoice form rendered from the model, with a client association select, typed inputs, and nested has_many line items (add/remove rows)."
425
491
  width="760">
426
492
  </a>
427
493
  </p>
428
494
  <p align="center">
429
- <em>An auto-generated form with a typed date field, an association select, and nested
430
- <code>has_many</code> line items — add and remove rows inline.</em>
495
+ <em>A form rendered from the model, with a typed date field, an association select, and
496
+ nested <code>has_many</code> line items — add and remove rows inline.</em>
431
497
  </p>
432
498
 
433
499
  **Basic forms:**
@@ -470,7 +536,17 @@ The form builder automatically detects field types:
470
536
  <%= f.association :category %>
471
537
  ```
472
538
 
473
- Automatically creates a select dropdown with all categories.
539
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/invoices/new) add and remove nested line items on a new invoice.
540
+
541
+ Creates a select dropdown for the association. The select is **searchable**: it's
542
+ enhanced with [Tom Select](https://tom-select.js.org) (vendored — no CDN, no
543
+ bundler) for keystroke typeahead. To keep large associations from rendering
544
+ thousands of `<option>`s, the initial list is capped at
545
+ `CafeCar.max_collection_options` (default 100); the typeahead then queries a JSON
546
+ `options` endpoint (`GET /categories/options?q=…`) so records **past the cap stay
547
+ reachable**. That endpoint is authorized through the model's policy scope, so the
548
+ search never returns rows the user can't see. Enhancement is progressive — if
549
+ JavaScript is disabled or fails, the field stays a working plain select.
474
550
 
475
551
  ### Filtering & Sorting
476
552
 
@@ -482,14 +558,78 @@ CafeCar provides advanced filtering with minimal configuration.
482
558
  /products?name=Widget&price.min=10&price.max=50&created_at=2024-01-01..2024-12-31
483
559
  ```
484
560
 
485
- **Filter operators:**
561
+ **Filter operators** — a bare column key filters that column; a `.operator`
562
+ suffix compares it:
563
+
564
+ - **Equals**: `status=active` (or `status.eq=active`)
565
+ - **Greater / less than**: `price.gt=10`, `price.lt=50`
566
+ - **At least / at most**: `price.min=10`, `price.max=50` (aliases: `price.gte`, `price.lte`)
567
+ - **Range**: `created_at=2024-01-01..2024-12-31` (`...` for an exclusive end)
568
+ - **Arrays (IN)**: `tags=red,blue,green`
569
+
570
+ Combine them freely — `?price.min=10&price.max=50` reads as `price BETWEEN 10 AND 50`.
571
+
572
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/users) — sort, filter, and search a real index.
573
+
574
+ **The filter panel:**
575
+
576
+ Every index also renders those filters as a form — a sticky card of controls
577
+ beside the table, with no configuration. CafeCar reads the model policy's
578
+ `permitted_filters` (which defaults to the displayable columns and associations)
579
+ and renders one **typed control per attribute**, matched to the column type:
580
+
581
+ - **string / text** — a "contains" box (substring match, the `~` operator)
582
+ - **integer / decimal / float / date / datetime** — a min/max pair (the range)
583
+ - **enum** — a select of the enum's own values (blank = Any)
584
+ - **boolean** — a tri-state select (Any / true / false)
585
+ - **belongs_to / has_many** — a searchable **multi-select** of the associated
586
+ records (the same Tom Select typeahead as the edit form), matching any of the
587
+ records you pick
588
+
589
+ The keyword-search box shares the card, and the whole panel submits as one GET
590
+ form — a submission just rewrites the query string above, with the active sort
591
+ and view riding along as hidden fields. Attachments, rich text, polymorphic
592
+ targets, and password columns render no control (there's nothing to filter on).
593
+
594
+ Named model scopes opt in separately through `permitted_scopes` and render as
595
+ checkbox toggles:
596
+
597
+ ```ruby
598
+ class ArticlePolicy < ApplicationPolicy
599
+ def permitted_filters = %i[title author published_at] # narrow the panel
600
+ def permitted_scopes = %i[published draft] # ?published=true
601
+ end
602
+ ```
603
+
604
+ The policy is the source of truth: change `permitted_filters` to change which
605
+ controls appear, and a URL filter for an attribute that isn't on the list is
606
+ dropped before it reaches the query. Control labels read from your locale
607
+ (`helpers.filter`, plus each model's attribute names), and you replace any
608
+ control by dropping a `_<type>_filter.html.haml` partial in the view directory.
609
+
610
+ **Filter through an association:**
611
+
612
+ Add a dot-path to `permitted_filters` and the panel filters by the far model's
613
+ attribute:
614
+
615
+ ```ruby
616
+ class InvoicePolicy < ApplicationPolicy
617
+ # own columns, plus a client's status and its owner
618
+ def permitted_filters = %i[status client.status client.owner_id]
619
+ end
620
+ ```
621
+
622
+ Each hop is the association name; the terminal is the far column, its enum, or a
623
+ belongs_to — `client.owner_id` and `client.owner` mean the same thing. The
624
+ control is typed by that terminal, so a nested belongs_to renders the same
625
+ searchable multi-select and a nested enum the same enum select as a top-level
626
+ one, and it composes with the other filters, search, and sort, round-tripping
627
+ into the URL like any other.
486
628
 
487
- - **Range queries**: `created_at=2024..2025-01-01`
488
- - **Comparisons**: `price.min=10`, `price.max=50`
489
- - **Greater than**: `price.gt=10` or `price=>10`
490
- - **Less than**: `price.lt=50` or `price=<50`
491
- - **Equals**: `status=active` or `status.eq=active`
492
- - **Arrays**: `tags=red,blue,green`
629
+ That one list is both the panel's source and the query whitelist: an undeclared
630
+ path is dropped before any join, even when it names a real column further out.
631
+ `?client.owner.email=` filters nothing unless `client.owner.email` is on
632
+ `permitted_filters` the same gate as an unpermitted top-level column.
493
633
 
494
634
  **Sorting:**
495
635
 
@@ -544,6 +684,41 @@ and logs a warning. Raise the limit in an initializer if your tables are larger:
544
684
  CafeCar.csv_export_row_limit = 50_000
545
685
  ```
546
686
 
687
+ **Chart view:**
688
+
689
+ Every index offers a third view beside grid and table. The **Chart** toggle
690
+ buckets records over a date column and plots them as a bar chart:
691
+
692
+ ```
693
+ /articles?view=chart&chart_x=published_at&chart_by=month
694
+ /invoices?view=chart&chart_x=issued_on&chart_y=sum:total
695
+ ```
696
+
697
+ Pick the x-axis from any of the model's displayable date/datetime columns
698
+ (`chart_x`) and the bucket size from `day`, `week`, or `month` (`chart_by`,
699
+ default `month`) using the form above the chart — it defaults to `created_at`
700
+ and month, so the chart renders with zero configuration. Only date columns the
701
+ policy exposes are offered, and the selected column is validated against that
702
+ allowlist, so the parameter is never used as a raw column name.
703
+
704
+ By default the bars measure the record count per bucket. `chart_y` switches that
705
+ to an aggregate of a numeric column: `count` (the default), `sum:<column>`, or
706
+ `avg:<column>` — for example `chart_y=sum:total`. The chartable columns are the
707
+ model's policy-permitted numeric attributes (integer, decimal, or float), the
708
+ same policy-is-source-of-truth pattern as the x-axis dates. A `chart_y` selector
709
+ renders above the chart only when the model has at least one such column;
710
+ otherwise you get the count chart with no selector. An unknown or non-permitted
711
+ `chart_y` is validated against the allowlist and falls back to `count`, so the
712
+ value never reaches SQL raw. Sum and average aggregate through a portable
713
+ ActiveRecord calculation that runs on both SQLite and Postgres.
714
+
715
+ The chart aggregates the **same** collection the table shows: the active filters
716
+ narrow it and `policy_scope` still applies, so it never plots rows the current
717
+ user can't see. Aggregation is a single database `GROUP BY` (a portable Arel date
718
+ truncation — `date_trunc` on Postgres, `strftime` on SQLite), and the result is
719
+ dependency-free inline SVG — no JavaScript, CSP-safe. A model with no date column
720
+ shows a short "no date columns to chart" message instead.
721
+
547
722
  **In models:**
548
723
 
549
724
  ```ruby
@@ -625,6 +800,159 @@ In views:
625
800
  <% end %>
626
801
  ```
627
802
 
803
+ ### Bulk Actions
804
+
805
+ Every index table carries a checkbox per row and a "select all" checkbox in the
806
+ header. Pick some rows, choose an action from the bar, and CafeCar applies it to
807
+ the selection. **Delete** ships built in.
808
+
809
+ Each selected record is authorized on its own: the candidate set is first
810
+ narrowed to the policy scope, then every row is checked against the action's
811
+ policy predicate (`destroy?` for delete). Rows the current user isn't allowed to
812
+ touch are skipped — a batch never bulk-bypasses a per-record denial.
813
+
814
+ **The policy is the source of truth.** A policy declares the actions its index
815
+ offers with `permitted_bulk_actions`, and the bar renders exactly that list:
816
+
817
+ ```ruby
818
+ class ArticlePolicy < ApplicationPolicy
819
+ def permitted_bulk_actions = %i[publish destroy]
820
+
821
+ def publish? = user.editor? # per-record authorization for the batch
822
+ def destroy? = !object.published?
823
+ end
824
+ ```
825
+
826
+ A custom action "just works" from three conventional pieces — no registration:
827
+
828
+ 1. list its name in `permitted_bulk_actions` (the policy),
829
+ 2. a **model bang method** for the behavior (`Article#publish!`),
830
+ 3. a **policy predicate** for authorization (`publish?`).
831
+
832
+ `batch` derives both from the name — it calls `record.publish!` on each row the
833
+ policy answers `publish?` for. Button labels and styles come from your locale
834
+ (`en.destroy: Delete`; `bulk_actions.styles.destroy: danger`), and you override
835
+ the whole bar for a resource by dropping an `_bulk_actions.html.haml` partial in
836
+ its view directory.
837
+
838
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/users) — select rows and delete the selection.
839
+
840
+ ### Member Actions
841
+
842
+ A **member action** runs one custom operation on a single record — a button on
843
+ the show page and on each index row, no row selection. It's the same three
844
+ conventional pieces as a bulk action, declared with `permitted_member_actions`:
845
+
846
+ ```ruby
847
+ class ArticlePolicy < ApplicationPolicy
848
+ def permitted_member_actions = %i[publish]
849
+
850
+ def publish? = !object.published? # authorizes the record, and shows/hides the button
851
+ end
852
+
853
+ class Article < ApplicationRecord
854
+ def publish! = update!(published_at: Time.current)
855
+ end
856
+ ```
857
+
858
+ The name resolves by convention — no registration: `publish?` authorizes and
859
+ `Article#publish!` runs. CafeCar renders a **Publish** button on the show page's
860
+ Actions card and on the record's index row, each a POST to
861
+ `/articles/:id/actions/publish`. A record whose `publish?` answers false renders
862
+ disabled with a tooltip instead of a link, so the one predicate gates both the
863
+ display and the request — a forged POST is re-authorized and refused, and the
864
+ bang method never runs.
865
+
866
+ The action replies with a redirect that Turbo morphs in place, so the row
867
+ reflows without a full page reload. The button's label and style come from your
868
+ locale (`en.publish`; `actions.styles.publish: primary`), never a hardcoded
869
+ string.
870
+
871
+ Override the forwarding by defining a `publish` controller method — it takes
872
+ over after authorization and owns the response. Override the buttons by dropping
873
+ an `_actions.html.haml` (show) or `_controls.html.haml` (index) partial in the
874
+ resource's view directory.
875
+
876
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/articles) — publish an article from its show page or index row.
877
+
878
+ ### Collection Actions
879
+
880
+ A **collection action** runs one action over a whole set at once, from a single
881
+ toolbar button — no row selection. It's the same three conventional pieces as a
882
+ bulk action, declared with `permitted_collection_actions`:
883
+
884
+ ```ruby
885
+ class ArticlePolicy < ApplicationPolicy
886
+ def permitted_collection_actions = %i[publish_all]
887
+
888
+ def publish_all? = user.editor? # authorizes against the model class
889
+ end
890
+
891
+ class Article < ApplicationRecord
892
+ def self.publish_all! = unpublished.update_all(published_at: Time.current)
893
+ end
894
+ ```
895
+
896
+ The button runs over the **currently-viewed, filtered set** — not the whole
897
+ table. It carries the active filters and search from the URL, so the action hits
898
+ exactly the records the index is showing. Filter `?author_id=7`, and **Publish
899
+ all** publishes that author's articles and nothing else. The button's label shows
900
+ the reach as a count — **Publish all 21** — from your locale
901
+ (`helpers.collection_action`, a `one`/`other` hash you can reword).
902
+
903
+ Names resolve like member/bulk actions: `publish_all?` authorizes, then
904
+ `Article.publish_all!` runs within the filtered scope. Override the forwarding by
905
+ defining a `publish_all` controller method (scope its own query), and override
906
+ the toolbar by dropping an `_index_actions.html.haml` partial in the view directory.
907
+
908
+ ### Dashboard
909
+
910
+ CafeCar can render a single **dashboard** overview — an at-a-glance page that
911
+ composes your data into metric tiles and charts. It's **opt-in the CafeCar way:
912
+ you write one view.** Its existence turns the dashboard on; delete it and there's
913
+ no dashboard at all (a direct hit 404s, no nav link), so a CRUD-only app never
914
+ inherits a blank page.
915
+
916
+ Write `app/views/cafe_car/dashboard/show.html.haml`:
917
+
918
+ ```haml
919
+ - title "Dashboard"
920
+
921
+ = Page title: "Dashboard" do |page|
922
+ = page.Body do
923
+ .Dashboard
924
+ = metrics User
925
+ = metric("Signups today") { User.where(created_at: Date.current.all_day).count }
926
+ = chart "New users", model: User, x: :created_at, by: :month
927
+ ```
928
+
929
+ Three helpers compose the page:
930
+
931
+ - **`metric("Label") { … }`** — a tile showing a label over the number your block
932
+ returns.
933
+ - **`metrics(Model)`** — the tiles a **policy** declares. `Model`'s policy lists
934
+ the scopes to surface in `permitted_metrics` (`:all` = the whole relation), and
935
+ CafeCar renders a count tile for each — the same policy-is-source-of-truth rule
936
+ as bulk actions.
937
+ - **`chart "Title", model:, x:, by:`** — the same dependency-free inline-SVG bar
938
+ chart as the index [Chart view](#advanced-usage), bucketing `model`'s records
939
+ over the `x` date column at `by` granularity (`:day`/`:week`/`:month`, default
940
+ `:month`). The `x` column is validated against the model's date-column allowlist
941
+ and truncated with portable Arel, so it's never interpolated as raw SQL.
942
+
943
+ ```ruby
944
+ class UserPolicy < ApplicationPolicy
945
+ def permitted_metrics = %i[all active] # total, then User.active.count
946
+ end
947
+ ```
948
+
949
+ Tiles render in a responsive grid at `dashboard_path` (no JavaScript, CSP-safe).
950
+ Because the dashboard is a plain view, you can drop in headings, your own
951
+ partials, or any markup between tiles. Once the template exists, a **Dashboard**
952
+ link appears at the top of the sidebar nav — no template, no link.
953
+
954
+ [See it live →](https://cafe-car-demo.up.railway.app/admin/dashboard) — metric tiles and a chart on one page.
955
+
628
956
  ### Current Context
629
957
 
630
958
  Access current request context anywhere:
@@ -760,8 +1088,66 @@ $ rails generate cafe_car:sessions
760
1088
  Creates the `sessions` table migration. The `CafeCar::Session` model and
761
1089
  `SessionPolicy` already ship with the engine.
762
1090
 
1091
+ ## Using CafeCar with AI coding agents
1092
+
1093
+ CafeCar ships an [Agent Skill](skills/cafe_car/SKILL.md) — the mental model (the policy
1094
+ declares, the UI renders) plus ten reference pages, written for coding agents. Install it
1095
+ into your app:
1096
+
1097
+ ```bash
1098
+ $ rails generate cafe_car:agents
1099
+ ```
1100
+
1101
+ This copies the skill to `.claude/skills/cafe_car/` (Claude Code) and
1102
+ `.agents/skills/cafe_car/` (Codex, Copilot, and other agents), and adds a marker-delimited
1103
+ pointer block to your `AGENTS.md` so agents read the skill before hand-rolling admin UI.
1104
+ Safe to re-run: only the marked block is replaced; the rest of your `AGENTS.md` is never
1105
+ touched.
1106
+
1107
+ Two more ways to put CafeCar docs in front of an agent:
1108
+
1109
+ ```bash
1110
+ $ npx skills add craft-concept/cafe_car # install the skill in any skills-aware tool
1111
+ ```
1112
+
1113
+ Or point an MCP-capable agent at
1114
+ [gitmcp.io/craft-concept/cafe_car](https://gitmcp.io/craft-concept/cafe_car) for live
1115
+ CafeCar docs with zero setup.
1116
+
763
1117
  ## Configuration
764
1118
 
1119
+ ### Theme
1120
+
1121
+ CafeCar ships three bundled themes, each a set of CSS custom properties with a
1122
+ built-in `prefers-color-scheme: dark` variant. Pick one with `CafeCar.theme`:
1123
+
1124
+ ```ruby
1125
+ # config/initializers/cafe_car.rb
1126
+ CafeCar.theme = :cool
1127
+ ```
1128
+
1129
+ | Theme | Look |
1130
+ | -------- | --------------------------------------------------------- |
1131
+ | `:warm` | Warm neutrals on off-white — the default. |
1132
+ | `:cool` | Cool blue-grey on a crisp light background. |
1133
+ | `:cool2` | The `cool` palette with translucent cards and darker dark mode. |
1134
+
1135
+ The selected theme is injected as a `<link>` into every CafeCar page's `<head>`,
1136
+ so it takes effect without recompiling assets. It defaults to `:warm` (the theme
1137
+ the engine has always shipped); an unknown value raises `ArgumentError`.
1138
+
1139
+ ### Association select size
1140
+
1141
+ `f.association` selects render at most `CafeCar.max_collection_options` options
1142
+ (default 100) to keep a large association from loading its whole target table into
1143
+ every form. Records past the cap stay reachable through the searchable select's
1144
+ typeahead feed (see [Forms](#forms)). Raise or lower the cap globally:
1145
+
1146
+ ```ruby
1147
+ # config/initializers/cafe_car.rb
1148
+ CafeCar.max_collection_options = 250
1149
+ ```
1150
+
765
1151
  ### Custom Form Builder
766
1152
 
767
1153
  ```ruby
@@ -827,6 +1213,8 @@ development setup, how to run the tests (`bundle exec rake`), and PR expectation
827
1213
  By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). To report a
828
1214
  security issue, see [SECURITY.md](SECURITY.md).
829
1215
 
1216
+ If CafeCar saved you an afternoon, a star helps other Rails developers find it.
1217
+
830
1218
  ## License
831
1219
 
832
1220
  The gem is available as open source under the terms of the
data/Rakefile CHANGED
@@ -19,6 +19,6 @@ end
19
19
 
20
20
  task default: %i[rubocop test brakeman]
21
21
 
22
- # Holdco backlog machinery (tasks:new, tasks:index, tasks:claim, tasks:done, rake task).
23
- # Loads every .rake under lib/tasks/ so the gem's own tasks and holdco_tasks both run.
22
+ # Load the gem's own rake tasks under lib/tasks/ (the backlog now lives on the
23
+ # holdco-tasks board see `bin/operate tasks`).
24
24
  Dir.glob(File.expand_path("lib/tasks/*.rake", __dir__)).sort.each { |f| load f }
@@ -0,0 +1,41 @@
1
+ @layer component {
2
+ .ChartControls {
3
+ display: flex;
4
+ gap: var(--half-gap);
5
+ align-items: center;
6
+ margin-block-end: var(--gap);
7
+ }
8
+
9
+ /* Fill the column at the SVG's fixed landscape viewBox ratio (see
10
+ ChartBuilder::WIDTH/HEIGHT). `height: auto` lets the height track that ratio,
11
+ so the chart stays a steady ~3.5:1 landscape at any bucket count instead of
12
+ rendering tall-and-narrow (few buckets) or flat (many). max-height keeps it
13
+ from getting oversized on very wide screens; the SVG's preserveAspectRatio
14
+ letterboxes cleanly if the cap trims the ratio. */
15
+ .Chart {
16
+ display: block;
17
+ width: 100%;
18
+ height: auto;
19
+ max-height: 360px;
20
+ color: var(--accent);
21
+ overflow: visible;
22
+ }
23
+
24
+ .Chart-bar rect {
25
+ transition: opacity var(--duration);
26
+ }
27
+
28
+ .Chart-bar:hover rect {
29
+ opacity: 0.8;
30
+ }
31
+
32
+ .Chart-value {
33
+ fill: var(--dim);
34
+ font-size: 11px;
35
+ }
36
+
37
+ .Chart-label {
38
+ fill: var(--dim);
39
+ font-size: 11px;
40
+ }
41
+ }