tsykvas_rails_template 0.1.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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +200 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +589 -0
  6. data/Rakefile +17 -0
  7. data/lib/generators/tsykvas_rails_template/companions/companions_generator.rb +273 -0
  8. data/lib/generators/tsykvas_rails_template/concept/concept_generator.rb +145 -0
  9. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.html.slim.tt +5 -0
  10. data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.rb.tt +11 -0
  11. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.html.slim.tt +5 -0
  12. data/lib/generators/tsykvas_rails_template/concept/templates/component/index.rb.tt +11 -0
  13. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.html.slim.tt +5 -0
  14. data/lib/generators/tsykvas_rails_template/concept/templates/component/new.rb.tt +11 -0
  15. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.html.slim.tt +4 -0
  16. data/lib/generators/tsykvas_rails_template/concept/templates/component/show.rb.tt +11 -0
  17. data/lib/generators/tsykvas_rails_template/concept/templates/controller.rb.tt +45 -0
  18. data/lib/generators/tsykvas_rails_template/concept/templates/operation/create.rb.tt +31 -0
  19. data/lib/generators/tsykvas_rails_template/concept/templates/operation/destroy.rb.tt +13 -0
  20. data/lib/generators/tsykvas_rails_template/concept/templates/operation/edit.rb.tt +10 -0
  21. data/lib/generators/tsykvas_rails_template/concept/templates/operation/index.rb.tt +9 -0
  22. data/lib/generators/tsykvas_rails_template/concept/templates/operation/new.rb.tt +10 -0
  23. data/lib/generators/tsykvas_rails_template/concept/templates/operation/show.rb.tt +10 -0
  24. data/lib/generators/tsykvas_rails_template/concept/templates/operation/update.rb.tt +31 -0
  25. data/lib/generators/tsykvas_rails_template/install/bootstrap_installer.rb +225 -0
  26. data/lib/generators/tsykvas_rails_template/install/install_generator.rb +298 -0
  27. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/buddy.md +157 -0
  28. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/code-reviewer.md +117 -0
  29. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/security-reviewer.md +113 -0
  30. data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/tech-lead.md +150 -0
  31. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/check.md +51 -0
  32. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/code-review.md +60 -0
  33. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/docs-create.md +102 -0
  34. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pr-review.md +81 -0
  35. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pushit.md +160 -0
  36. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/refactor.md +132 -0
  37. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/task-sum.md +47 -0
  38. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tests.md +67 -0
  39. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tsykvas-claude.md +262 -0
  40. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-docs.md +78 -0
  41. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-rules.md +102 -0
  42. data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-tests.md +135 -0
  43. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/architecture.md +315 -0
  44. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/authentication.md +96 -0
  45. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/background-jobs.md +135 -0
  46. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/code-style.md +101 -0
  47. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/commands.md +34 -0
  48. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/companions.md +128 -0
  49. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md +194 -0
  50. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/database.md +135 -0
  51. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/deployment.md +138 -0
  52. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/design-system.md +322 -0
  53. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/documentation.md +89 -0
  54. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/forms.md +174 -0
  55. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/i18n.md +165 -0
  56. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md +114 -0
  57. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/security.md +122 -0
  58. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md +166 -0
  59. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing-examples.md +180 -0
  60. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing.md +117 -0
  61. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/tsykvas_rails_template.md +280 -0
  62. data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/ui-components.md +196 -0
  63. data/lib/generators/tsykvas_rails_template/install/templates/CLAUDE.md.tt +81 -0
  64. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/component/base.rb +6 -0
  65. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/base.rb +124 -0
  66. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/result.rb +56 -0
  67. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.html.slim +49 -0
  68. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.rb +11 -0
  69. data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/operation/index.rb +17 -0
  70. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/concerns/operations_methods.rb +148 -0
  71. data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/home_controller.rb +10 -0
  72. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/application_policy.rb +33 -0
  73. data/lib/generators/tsykvas_rails_template/install/templates/app/policies/home_policy.rb +8 -0
  74. data/lib/tasks/tsykvas.rake +11 -0
  75. data/lib/tsykvas_rails_template/probe.rb +236 -0
  76. data/lib/tsykvas_rails_template/railtie.rb +13 -0
  77. data/lib/tsykvas_rails_template/version.rb +5 -0
  78. data/lib/tsykvas_rails_template.rb +18 -0
  79. metadata +183 -0
@@ -0,0 +1,322 @@
1
+ # Design System
2
+
3
+ > **This is a Bootstrap-default starting point.** Customise tokens, typography, and component patterns to match your brand. The structure below shows what to document so future contributors (humans and AI) know the rules.
4
+
5
+ The shipped install wires `bootstrap` 5.3 + `dartsass-rails` and pre-compiles `app/assets/stylesheets/application.bootstrap.scss` → `app/assets/builds/application.css`. All tokens below come from Bootstrap's CSS custom properties unless your app overrides them.
6
+
7
+ If you change SCSS in `app/assets/stylesheets/` or add a new visual primitive under `app/concepts/base/component/`, update this file in the same PR.
8
+
9
+ ## Principles
10
+
11
+ 1. **Tokens before values.** Use `--bs-primary` / `$primary`, never hex literals.
12
+ 2. **Type over colour.** Hierarchy comes from spacing and type weight first, colour second.
13
+ 3. **One destructive cue.** `--bs-danger` (or your override) is reserved for destructive / single-CTA contexts. Don't sprinkle it.
14
+ 4. **Components beat CSS.** New visual rules belong in `Base::Component::*` (or a sibling), not in inline class soup.
15
+ 5. **One stylesheet entrypoint.** Everything imports from `application.bootstrap.scss`. No per-feature global CSS files.
16
+
17
+ ## Tokens
18
+
19
+ ### Palette — light mode (Bootstrap defaults)
20
+
21
+ ```scss
22
+ $primary: #0d6efd; // --bs-primary
23
+ $secondary: #6c757d; // --bs-secondary
24
+ $success: #198754; // --bs-success
25
+ $info: #0dcaf0; // --bs-info
26
+ $warning: #ffc107; // --bs-warning
27
+ $danger: #dc3545; // --bs-danger
28
+ $light: #f8f9fa; // --bs-light
29
+ $dark: #212529; // --bs-dark
30
+ ```
31
+
32
+ Surface tokens (page background, body text, hairline borders):
33
+
34
+ ```scss
35
+ $body-bg: #fff;
36
+ $body-color: #212529;
37
+ $border-color: #dee2e6;
38
+ ```
39
+
40
+ To rebrand: override the SCSS variables **above** the `@import "bootstrap"` line in `application.bootstrap.scss`. Bootstrap's `_variables.scss` uses `!default`, so your override wins.
41
+
42
+ ```scss
43
+ // application.bootstrap.scss
44
+ $primary: #1F3D2C; // your brand primary
45
+ $body-bg: #F4EFE6; // your page background
46
+ @import "bootstrap";
47
+ ```
48
+
49
+ ### Palette — dark mode
50
+
51
+ Bootstrap 5.3 ships `data-bs-theme="dark"` switching via attribute. Toggle via a Stimulus controller (see `stimulus-controllers.md` — there's a `theme_controller.js` template that mirrors `data-bs-theme` on `<body>` from a `localStorage` toggle).
52
+
53
+ To customise dark-mode tokens, override `[data-bs-theme="dark"]` after the import:
54
+
55
+ ```scss
56
+ @import "bootstrap";
57
+
58
+ [data-bs-theme="dark"] {
59
+ --bs-body-bg: #1a1a1a;
60
+ --bs-body-color: #e9ecef;
61
+ }
62
+ ```
63
+
64
+ ### Typography
65
+
66
+ Bootstrap default: system font stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...`). Sizes:
67
+
68
+ | Token | Default | Use |
69
+ |---|---|---|
70
+ | `$h1-font-size` | 2.5rem | Page hero headings |
71
+ | `$h2-font-size` | 2rem | Section headings |
72
+ | `$h3-font-size` | 1.75rem | Sub-section headings |
73
+ | `$h4-font-size` | 1.5rem | Card / dashboard tile headings |
74
+ | `$h5-font-size` | 1.25rem | Form section headings |
75
+ | `$h6-font-size` | 1rem | Inline labels (caps + tracking) |
76
+ | `$font-size-base` | 1rem | Body |
77
+ | `$font-size-sm` | 0.875rem | Helper text, table headers |
78
+
79
+ To swap fonts (e.g. a Google Font for headings, Inter for body):
80
+
81
+ ```scss
82
+ $font-family-sans-serif: "Inter", system-ui, -apple-system, sans-serif;
83
+ $headings-font-family: "Playfair Display", Georgia, serif; // optional
84
+ @import "bootstrap";
85
+ ```
86
+
87
+ Then add `<link>` tags or `@import url(...)` in your stylesheet. Document the exact fonts here once chosen.
88
+
89
+ ### Geometry
90
+
91
+ ```scss
92
+ $border-radius: 0.375rem; // default cards, inputs, buttons
93
+ $border-radius-sm: 0.25rem; // badges, small chips
94
+ $border-radius-lg: 0.5rem; // modals, alerts
95
+ $border-radius-xl: 1rem; // hero-section panels (rare)
96
+ $border-radius-pill: 50rem; // pills (avoid for primary CTAs)
97
+ ```
98
+
99
+ Spacing scale — Bootstrap's `0` through `5`:
100
+
101
+ | Token | Value | Common use |
102
+ |---|---|---|
103
+ | `$spacer` | 1rem | Base unit |
104
+ | `0` | 0 | Reset |
105
+ | `1` | 0.25rem | Inline gap |
106
+ | `2` | 0.5rem | Component-internal spacing |
107
+ | `3` | 1rem | Card body padding |
108
+ | `4` | 1.5rem | Section spacing |
109
+ | `5` | 3rem | Page-level rhythm |
110
+
111
+ Use the utility classes (`.p-3`, `.mb-4`, `.gap-2`) — don't hand-roll margins in component SCSS.
112
+
113
+ ### Motion
114
+
115
+ Bootstrap ships `--bs-transition` and component-specific durations. Defaults work for most cases; customise via:
116
+
117
+ ```scss
118
+ $transition-base: all 0.2s ease-in-out;
119
+ $transition-fade: opacity 0.15s linear;
120
+ $transition-collapse: height 0.35s ease;
121
+ ```
122
+
123
+ Reduce-motion: respect `@media (prefers-reduced-motion: reduce)` in custom animations — Bootstrap already does this for built-ins.
124
+
125
+ ## Component catalog
126
+
127
+ ### Buttons (`Base::Component::Btn`)
128
+
129
+ Wraps Bootstrap `<button>` / `<a>` with a typed API. See [`ui-components.md`](ui-components.md#buttons-always-use-basecomponentbtn) for full props. Visual rules:
130
+
131
+ - One `.btn-primary` per page (primary action).
132
+ - Use `.btn-outline-secondary` for secondary actions.
133
+ - Reserve `.btn-danger` for destructive operations.
134
+ - Avoid pill-shaped buttons (`$border-radius-pill`) for primary CTAs.
135
+
136
+ Sizes via `$btn-padding-y-{sm,lg}` / `$btn-font-size-{sm,lg}`. Stick to the `XS` / `SM` / `M` / `L` mapping in `Base::Component::Btn`.
137
+
138
+ ### Stat cards (dashboard tiles)
139
+
140
+ ```scss
141
+ .stat-card {
142
+ background: var(--bs-body-bg);
143
+ border: 1px solid var(--bs-border-color);
144
+ border-radius: var(--bs-border-radius);
145
+ padding: 1.5rem;
146
+ }
147
+
148
+ .stat-card--emphasis {
149
+ background: var(--bs-primary);
150
+ color: var(--bs-white);
151
+ border-color: var(--bs-primary);
152
+ }
153
+
154
+ .stat-card-label {
155
+ text-transform: uppercase;
156
+ letter-spacing: 0.18em;
157
+ font-size: 0.75rem;
158
+ color: var(--bs-secondary-color);
159
+ }
160
+
161
+ .stat-card-figure {
162
+ font-size: 3rem;
163
+ font-weight: 600;
164
+ display: block;
165
+ }
166
+
167
+ .stat-card-description {
168
+ font-size: 0.875rem;
169
+ color: var(--bs-secondary-color);
170
+ margin: 0;
171
+ }
172
+ ```
173
+
174
+ Use one `.stat-card--emphasis` per dashboard for the primary metric; `.stat-card` for everything else.
175
+
176
+ ### Information card (`Base::Component::InformationCard`)
177
+
178
+ Detail card pattern used on user-show / property-show pages. Composition:
179
+
180
+ - Header strip — `padding: 1rem 1.5rem; background: var(--bs-light);`
181
+ - Monogram tile — square 48px hairline-bordered, no rounded avatar
182
+ - `.section-title` rendered as small uppercase label + hairline divider
183
+
184
+ ### Cards (general)
185
+
186
+ Bootstrap's `.card` defaults are fine. Augment with `.card.shadow-sm` for elevated cards (e.g. the gem's Home example). Don't reach for `box-shadow: 0 4px 12px ...` per-component — use the shadow tokens.
187
+
188
+ ```scss
189
+ $box-shadow-sm: 0 .125rem .25rem rgba($black, .075);
190
+ $box-shadow: 0 .5rem 1rem rgba($black, .15);
191
+ $box-shadow-lg: 0 1rem 3rem rgba($black, .175);
192
+ ```
193
+
194
+ ### Tables (`Base::Component::Table::Table`)
195
+
196
+ Hairline tables, no zebra striping by default. Action buttons inside cells use `.table-action-btn` (32px square hairline). Wrap inside `.table-responsive` for mobile collapse.
197
+
198
+ ### Forms
199
+
200
+ Two visual variants:
201
+
202
+ - `.form-control` — Bootstrap default. Boxed input, hairline border, primary focus halo.
203
+ - `.form-control--underline` — auth-page variant: no box, just a bottom hairline that thickens on focus.
204
+
205
+ ```scss
206
+ .form-control--underline {
207
+ border: 0;
208
+ border-bottom: 1px solid var(--bs-border-color);
209
+ border-radius: 0;
210
+ padding-left: 0;
211
+ padding-right: 0;
212
+
213
+ &:focus {
214
+ box-shadow: none;
215
+ border-bottom-width: 2px;
216
+ border-bottom-color: var(--bs-primary);
217
+ }
218
+ }
219
+ ```
220
+
221
+ Apply the underline variant via a wrapping `.auth-form` selector — see `auth.scss`.
222
+
223
+ ### Sidebar (`Shared::Sidebar::Component`)
224
+
225
+ Full-height left rail, hairline divider on the right edge. Nav links use `display: flex; width: 100%;` so the active/hover bar spans the full width — don't override.
226
+
227
+ ### Navbar
228
+
229
+ Top bar, hairline divider on the bottom edge. The brand-lockup composition (logo + wordmark) lives in `Shared::Navbar::*`.
230
+
231
+ ### Auth (sign-in / sign-up / edit profile)
232
+
233
+ Centred card, no sidebar/navbar. Uses `.form-control--underline` inputs and a single `.btn-primary` submit. Sign-up form fields belong in `app/views/devise/registrations/new.html.slim`.
234
+
235
+ ## Utility classes
236
+
237
+ ### Typography
238
+
239
+ - `.section-label` — small uppercase tracked label (use for stat-card labels, navigational micro-headers).
240
+ - `.caps` — looser uppercase tracking (0.12em).
241
+ - `.figure` / `.figure-lg` — large display numbers.
242
+ - `.display-1` through `.display-6` — Bootstrap's display headings.
243
+
244
+ ### Color
245
+
246
+ Use Bootstrap's `.text-{primary,secondary,muted,success,warning,danger,info,light,dark}` and matching `.bg-*`. Don't introduce new colour utilities for one-offs — reach for a token override instead.
247
+
248
+ ### Layout
249
+
250
+ - `.container` / `.container-fluid` — Bootstrap's responsive containers.
251
+ - `.row.g-{0..5}` — flex grid with gap.
252
+ - `.col-{xs,sm,md,lg,xl}-{1..12}` — column spans.
253
+ - `.d-flex` / `.gap-{1..5}` / `.justify-content-*` / `.align-items-*` — flex utilities.
254
+
255
+ ## I18n integration
256
+
257
+ Brand-/UI-only strings live under a dedicated `ui:` namespace (see `i18n.md`):
258
+
259
+ ```yaml
260
+ en:
261
+ ui:
262
+ sections:
263
+ workspace: "Workspace"
264
+ account: "Account"
265
+ administration: "Administration"
266
+ brand:
267
+ tagline: "..."
268
+ year: "2026"
269
+ ```
270
+
271
+ Reference via `I18n.t('ui.sections.workspace')`. Keep typographic constants identical across locales (`year: "2026"` is the same in every locale).
272
+
273
+ ## Antipatterns
274
+
275
+ ### Color
276
+
277
+ - ❌ Hex literals scattered through component CSS → ✅ tokens (`var(--bs-primary)`, `$primary`).
278
+ - ❌ Coloured Bootstrap card backgrounds (`.card.bg-primary` for stats) → ✅ `.stat-card` / `.stat-card--emphasis`.
279
+ - ❌ Using `--bs-danger` for non-destructive UI just because it stands out → ✅ reserve danger for destructive/CTA only.
280
+
281
+ ### Geometry
282
+
283
+ - ❌ `border-radius: 9999px` (pill shape) on primary action buttons → ✅ `$border-radius` (default).
284
+ - ❌ Custom `box-shadow: 0 4px 12px ...` per component → ✅ `$box-shadow-sm` / `$box-shadow`.
285
+
286
+ ### Typography
287
+
288
+ - ❌ Inline `font-family` overrides on individual elements → ✅ set once in `$font-family-sans-serif` / `$headings-font-family`.
289
+ - ❌ Hardcoded `text-uppercase` + inline `letter-spacing` → ✅ `.section-label` / `.caps` utility class.
290
+ - ❌ Inline font-size literals (`font-size: 14px`) → ✅ `$font-size-sm` token.
291
+
292
+ ### Components
293
+
294
+ - ❌ Hand-rolled buttons / cards / modals → ✅ use `Base::Component::*` or Bootstrap directly.
295
+ - ❌ Class soup (`class="bg-primary text-white text-center fw-bold p-3 rounded shadow"`) → ✅ extract a small SCSS utility class or component partial.
296
+ - ❌ `Base::Component::Btn` calls without leading `::` from inside another component → ✅ `::Base::Component::Btn.new(...)` (constant lookup).
297
+
298
+ ### Layout / interaction
299
+
300
+ - ❌ Per-page custom margins → ✅ Bootstrap spacing utilities (`.mb-4`, `.gap-3`).
301
+ - ❌ Sidebar `.nav-link` without `display: flex; width: 100%` → ✅ active bar must span full sidebar width.
302
+ - ❌ `active_nav_class('/crm')` for a dashboard link (matches every CRM page) → ✅ `active_nav_class('/crm', '/crm/dashboard', exact: true)`.
303
+
304
+ ## Adding new tokens or components
305
+
306
+ 1. **Add the token to `application.bootstrap.scss`** above the `@import "bootstrap"` line.
307
+ 2. **Document it in this file** under the appropriate section.
308
+ 3. **If it's component-shaped**, create a sibling under `Base::Component::*` and document the API in [`ui-components.md`](ui-components.md).
309
+ 4. **Run `bin/rails dartsass:build`** locally to verify the SCSS compiles without warnings.
310
+ 5. **Update the antipatterns** if the new token replaces an existing pattern (so future contributors don't reach for the old one).
311
+
312
+ ## File map
313
+
314
+ ```
315
+ app/assets/stylesheets/
316
+ application.bootstrap.scss # entry point — your overrides + @import "bootstrap"
317
+ components/ # per-component SCSS partials (stat-card, info-card, ...)
318
+ utilities/ # custom utility classes (.section-label, .caps, ...)
319
+ layouts/ # per-layout SCSS (sidebar, navbar, auth, ...)
320
+
321
+ app/concepts/base/component/ # Ruby ViewComponents (Btn, Table, TitleRow, InformationCard, ...)
322
+ ```
@@ -0,0 +1,89 @@
1
+ # Documentation Standards
2
+
3
+ ## Directory structure
4
+
5
+ ```
6
+ docs/
7
+ ├── <domain>/ # Feature domain directory (admin, crm, screener, ...)
8
+ │ ├── overview.md # Domain-level overview (entry points, key concepts)
9
+ │ ├── operations.md # Operations and their flows
10
+ │ ├── components.md # ViewComponents in this domain
11
+ │ └── <specific_feature>.md # Specific feature docs
12
+ └── <standalone_topic>.md # Cross-domain infrastructure topics
13
+ ```
14
+
15
+ ### Existing domains
16
+
17
+ `admin`, `crm`, `screener`, `shared`, `base`. New documentation should slot into one of these or sit at the root if cross-cutting.
18
+
19
+ ### Placement rules
20
+
21
+ - Feature belongs to an existing domain → put in that domain's directory
22
+ - Feature crosses multiple domains → primary domain, reference from others
23
+ - Standalone infrastructure → root of `docs/`
24
+
25
+ ## When to create documentation
26
+
27
+ - New feature with non-obvious flow (multi-file, jobs, API calls)
28
+ - Bug fix that reveals non-obvious behavior worth remembering
29
+ - Flow that involves race conditions, caching, or async processing
30
+ - Integration with an external service
31
+
32
+ ## When NOT to create documentation
33
+
34
+ - Simple CRUD operations that follow the Concepts Pattern
35
+ - Trivial UI changes
36
+ - Something already well-documented in code
37
+
38
+ ## Document template
39
+
40
+ ```markdown
41
+ # <Feature Name>
42
+
43
+ > Last updated: YYYY-MM-DD
44
+
45
+ <1–2 sentence summary.>
46
+
47
+ ---
48
+
49
+ ## Overview
50
+
51
+ <What this feature does, why it exists, who uses it.>
52
+
53
+ ---
54
+
55
+ ## Flow
56
+
57
+ <ASCII diagram or numbered steps from trigger to result.>
58
+
59
+ ```
60
+ Trigger (UI / Job / API)
61
+ └─ Controller#action
62
+ └─ Operation#perform!
63
+ └─ Service / Job / etc.
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Key Files
69
+
70
+ | File | Role |
71
+ |------|------|
72
+ | `app/path/to/file.rb` | What it does |
73
+
74
+ ---
75
+
76
+ ## Potential Issues
77
+
78
+ | # | Location | Description | Severity |
79
+ |---|----------|-------------|----------|
80
+ | 1 | `Class#method` | Description | Low/Medium/High |
81
+ ```
82
+
83
+ ## Writing rules
84
+
85
+ - **Audience**: developers on this team, not end users.
86
+ - **Be specific**: use file paths, method names, class names.
87
+ - **Include the full flow**: don't skip steps.
88
+ - **Document behavior, not implementation**: focus on what happens, not line-by-line code.
89
+ - **Language**: English.
@@ -0,0 +1,174 @@
1
+ # Forms — when to introduce a Form object
2
+
3
+ The default flow for simple CRUD is to permit params directly inside the
4
+ operation:
5
+
6
+ ```ruby
7
+ class Crm::Property::Operation::Create < ::Base::Operation::Base
8
+ def perform!(params:, current_user:)
9
+ property = Crm::Property.new
10
+ authorize! property, :create?
11
+
12
+ property.assign_attributes(property_params(params))
13
+ property.save!
14
+
15
+ self.model = property
16
+ self.redirect_path = crm_property_path(property)
17
+ notice(I18n.t("crm.property.create.success"))
18
+ end
19
+
20
+ private
21
+
22
+ def property_params(params)
23
+ params.require(:property).permit(:name, :description)
24
+ end
25
+ end
26
+ ```
27
+
28
+ This is fine when the form maps 1:1 to ActiveRecord columns. Once the form
29
+ gets non-trivial, **promote `_params` into a dedicated `<Concept>::Form`
30
+ class** instead of growing the operation.
31
+
32
+ ## When to promote to a Form object
33
+
34
+ Introduce a `Form` class as soon as **any** of these is true:
35
+
36
+ - The form has **virtual attributes** that aren't AR columns (e.g. a
37
+ settlement reference, an external API code, raw address pieces that resolve
38
+ to a city_id).
39
+ - The form needs **pre-assignment cleanup** (deep symbolize, reject blanks,
40
+ coerce arrays, normalize booleans).
41
+ - The form **calls a sub-operation mid-assignment** (e.g. resolving a
42
+ `settlement_ref` to a `city_id` before the AR record is saved).
43
+ - The form aggregates **multiple AR records** under one submit (e.g.
44
+ parent + nested has_many with custom permit logic).
45
+ - Strong params alone can't express the rule (conditional permits, branches
46
+ by user role, attributes coming from multiple top-level keys).
47
+
48
+ If none of those hold, keep the inline `_params` method. **Do not introduce
49
+ a Form object preemptively** — simple CRUD does not need the indirection.
50
+
51
+ ## The pattern
52
+
53
+ Layout:
54
+
55
+ ```
56
+ app/concepts/<feature>/
57
+ ├── form.rb <- the Form class
58
+ ├── operation/
59
+ │ ├── create.rb <- uses the Form
60
+ │ ├── update.rb <- uses the Form
61
+ │ └── …
62
+ └── component/…
63
+ ```
64
+
65
+ Class shape:
66
+
67
+ ```ruby
68
+ class <Concept>::Form
69
+ ASSIGNABLE_TO_AR = %i[name description …].freeze
70
+ ASSIGNABLE_NESTED = %i[photos_attributes working_hours_attributes].freeze
71
+ VIRTUAL_ATTRIBUTES = %i[external_ref settlement_name source_code].freeze
72
+
73
+ attr_reader :record, *VIRTUAL_ATTRIBUTES
74
+
75
+ delegate :valid?, :invalid?, :errors, :save, :save!, to: :record
76
+
77
+ def initialize(record)
78
+ @record = record
79
+ VIRTUAL_ATTRIBUTES.each { |a| instance_variable_set("@#{a}", nil) }
80
+ end
81
+
82
+ def assign(raw_params)
83
+ return self if raw_params.blank?
84
+
85
+ permitted = sanitize(raw_params)
86
+ extract_virtual_attributes!(permitted)
87
+ @record.assign_attributes(permitted)
88
+ self
89
+ end
90
+
91
+ private
92
+
93
+ def sanitize(raw_params)
94
+ raw = raw_params.respond_to?(:to_unsafe_h) ? raw_params.to_unsafe_h : raw_params.to_h
95
+ sym = raw.deep_symbolize_keys
96
+
97
+ permitted = {}
98
+ ASSIGNABLE_TO_AR.each { |k| permitted[k] = sym[k] if sym.key?(k) }
99
+ ASSIGNABLE_NESTED.each { |k| permitted[k] = sym[k] if sym.key?(k) }
100
+ VIRTUAL_ATTRIBUTES.each { |k| permitted[k] = sym[k] if sym.key?(k) }
101
+ permitted
102
+ end
103
+
104
+ def extract_virtual_attributes!(permitted)
105
+ VIRTUAL_ATTRIBUTES.each do |a|
106
+ next unless permitted.key?(a)
107
+
108
+ instance_variable_set("@#{a}", permitted.delete(a))
109
+ end
110
+ end
111
+ end
112
+ ```
113
+
114
+ Usage from the operation:
115
+
116
+ ```ruby
117
+ def perform!(params:, current_user:)
118
+ record = <Resource>.new
119
+ authorize! record, :create?
120
+
121
+ form = <Concept>::Form.new(record).assign(params[:<concept>])
122
+ apply_external_resolution!(form, current_user) if form.external_ref.present?
123
+
124
+ record.save!
125
+
126
+ self.model = record
127
+ self.redirect_path = "/<plural>/#{record.id}"
128
+ notice(I18n.t("<concept>.create.success"))
129
+ end
130
+
131
+ private
132
+
133
+ def apply_external_resolution!(form, current_user)
134
+ result = <Concept>::Operation::ResolveSomething.call(
135
+ params: { ref: form.external_ref },
136
+ current_user: current_user
137
+ )
138
+
139
+ if result.failure?
140
+ form.record.errors.add(:base, I18n.t("<concept>.create.errors.unresolved"))
141
+ raise ActiveRecord::RecordInvalid.new(form.record)
142
+ end
143
+
144
+ form.assign_resolved!(result.model)
145
+ end
146
+ ```
147
+
148
+ ## What NOT to do
149
+
150
+ - **Do not put complex param logic inside the operation's `_params` method.**
151
+ Once you need virtual attributes, conditional permits, or sub-operations,
152
+ promote to a Form object.
153
+ - **Do not use Reform, dry-validation, or other DSL gems.** This stack is
154
+ intentionally plain Ruby — Form objects are POROs that delegate validations
155
+ to the AR record.
156
+ - **Do not skip `authorize!`** because the Form is involved. The operation
157
+ still authorizes the AR record before calling `form.assign(...)`.
158
+ - **Do not generate a Form via the concept generator.** The
159
+ `tsykvas_rails_template:concept` scaffold intentionally emits the inline
160
+ `_params` shape — it covers the simple case. Promote by hand when the
161
+ form actually needs it.
162
+
163
+ ## Checklist when promoting
164
+
165
+ 1. Move strong-params logic out of the operation into `app/concepts/<feature>/form.rb`.
166
+ 2. Update `Operation::Create` and `Operation::Update` to call
167
+ `Form.new(record).assign(params[:concept])` instead of
168
+ `record.assign_attributes(_params(params))`.
169
+ 3. Add a spec for the Form: `spec/concepts/<feature>/form_spec.rb`. Test that
170
+ permitted attributes are kept, virtual attributes are extracted into reader
171
+ methods, and unknown keys are rejected.
172
+ 4. If virtual attributes drive a sub-operation, write a request spec
173
+ exercising the failure path (e.g. unresolvable settlement) so the
174
+ `add_error` + `raise ActiveRecord::RecordInvalid` flow is covered.