Class: Seams::Generators::DesignGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
Includes:
EjectAware, HostInjector
Defined in:
lib/generators/seams/design/design_generator.rb

Overview

Generates the canonical Design engine on top of the generic engine scaffold — Phase 1 (this unit): the engine skeleton, the non-isolated wiring that makes ui_* helpers + ui/ partials visible host-wide, the Tailwind v4 token injection, the FormBuilder default, and the icon sprite render.

The design engine is DELIBERATELY NOT isolate_namespaced (D4 in proposals/design_system_engine.md). The whole value is that a component renders anywhere in the host and in every other engine's views without ceremony, which requires the partials and the helper to live in the host's view paths and ActionController::Base. The base EngineGenerator produces an isolated engine, so this generator overwrites lib/design/engine.rb with the non-isolated form and removes the single-namespace leftovers the base scaffold ships.

Naming (D1): the engine, CLI verb, folder and Ruby namespace are design (Design::); the VIEW + HELPER surface is ui — partials at app/views/ui/_.html.erb, previews at app/views/ui/previews/, and auto-derived helpers named ui_<name>.

Later sub-issues import this skeleton: #17 (tokens/theme), #18 (helpers + gallery + tests), #19 (FormBuilder + form components), #20 (the design:component generator), and the Phase 2/3 component + shell units.

Run with: bin/seams design (or bin/rails generate seams:design)

Like the admin generator, this is a long-but-flat orchestration class: each public method is one small, single-purpose generate step, so the length is inherent to the number of files the engine ships, not tangled logic. rubocop:disable-next Metrics/ClassLength

Constant Summary collapse

ENGINE_NAME =
"design"

Constants included from EjectAware

EjectAware::EJECT_HEADER_PREFIX

Instance Method Summary collapse

Methods included from EjectAware

#ejected?, #template_unless_ejected

Methods included from HostInjector

#host_inject_gem, #host_inject_include_in_application_controller, #host_inject_include_in_user, #host_inject_mount, #host_uninject_gem, #host_uninject_include, #host_uninject_mount, #routes_draw_anchor

Instance Method Details

#create_actions_components ⇒ Object

The Actions & status component set (Phase 2, GROUPKEY = actions). Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised in the previews), each carrying its baked-in accessibility:

- banner   a page-level role=region announcement with a tone variant;
- toast    a role=status transient notification;
- note     an inline annotation span.

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



269
270
271
272
273
274
275
276
# File 'lib/generators/seams/design/design_generator.rb', line 269

def create_actions_components
  %w[banner toast note].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_auto_wire ⇒ Object

The auto-wire registry: Design.component_names derives the public component list from the preview partials, and resets on reload so a new component appears without a server restart. Ported from quire-saas's lib/compositor.rb (compositor -> design, compositor/previews -> ui/previews).



111
112
113
# File 'lib/generators/seams/design/design_generator.rb', line 111

def create_auto_wire
  template "lib/design/components.rb.tt", engine_path("lib/design/components.rb")
end

#create_base_engine ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/generators/seams/design/design_generator.rb', line 59

def create_base_engine
  # The base EngineGenerator raises if engines/design/ already exists.
  # Skip it on a re-run so a second `bin/seams design` is a no-op on the
  # engine and simply re-applies the (idempotent) host wiring below.
  if File.directory?(engine_path(""))
    say "  exist   engines/design (kept — re-applying host wiring only)", :blue
    return
  end

  EngineGenerator.start([ENGINE_NAME], destination_root: destination_root)
end

#create_component_generator ⇒ Object

The design:component generator (#20): ships INSIDE the generated engine at engines/design/lib/generators/design/component/, so a host can run rails g design:component <name> (Rails auto-discovers it on the engine's lib path; no registration needed). Ported from quire-saas's Compositor.

copy_file (NOT template): the generator's own .tt templates are ERB run by rails g design:component, so they must reach the engine VERBATIM. The meta-generator's ERB would un-escape their <%% markers and nest the <%= file_name %> placeholders inside a real tag — a parse error.



326
327
328
329
330
331
332
333
# File 'lib/generators/seams/design/design_generator.rb', line 326

def create_component_generator
  copy_file "lib/generators/design/component/component_generator.rb.tt",
            engine_path("lib/generators/design/component/component_generator.rb")
  copy_file "lib/generators/design/component/templates/component.html.erb.tt",
            engine_path("lib/generators/design/component/templates/component.html.erb.tt")
  copy_file "lib/generators/design/component/templates/preview.html.erb.tt",
            engine_path("lib/generators/design/component/templates/preview.html.erb.tt")
end

#create_data_components ⇒ Object

The Data display component set (Phase 2, GROUPKEY = data). Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised in the previews), each carrying its baked-in accessibility:

- card         a titled content surface;
- data_table   a <table> with a caption + scoped headers;
- chapter_row  a manuscript chapter list row;
- build_row    an export/build status list row;
- counter      a labelled numeric stat;
- meter        a <meter>-backed progress indicator;
- kbd          a <kbd> keyboard-shortcut glyph.

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



293
294
295
296
297
298
299
300
# File 'lib/generators/seams/design/design_generator.rb', line 293

def create_data_components
  %w[card data_table chapter_row build_row counter meter kbd].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_example_theme ⇒ Object

Ship the example "quire" theme (#27) into the host as a token overlay the host can opt into. It is NOT applied by default (the neutral theme owns the default, per the proposal) — it sits alongside application.css as the worked proof that retheming is a token override: add one @import "themes/quire"; line and the whole app reskins. The theming guide (doc/design-system/DESIGN_SYSTEM_THEMING.md) documents it. Eject-aware.



378
379
380
381
# File 'lib/generators/seams/design/design_generator.rb', line 378

def create_example_theme
  template_unless_ejected "app/assets/tailwind/themes/_quire.css",
                          host_path("app/assets/tailwind/themes/_quire.css")
end

#create_form_builder ⇒ Object

The default form builder. Subclasses the standard Rails builder and only ADDS ui_* methods, so it is safe as the app-wide default. Ported from quire-saas's app/form_builders/compositor/form_builder.rb (compositor_* -> ui_*, the field partial path compositor/field -> ui/field). #19 fleshes out the textarea/select/submit helpers + the ui/field partial.



128
129
130
131
# File 'lib/generators/seams/design/design_generator.rb', line 128

def create_form_builder
  template "app/form_builders/design/form_builder.rb.tt",
           engine_path("app/form_builders/design/form_builder.rb")
end

#create_form_components ⇒ Object

The form-input component set (#19): the building blocks Design::FormBuilder and hand-written forms render. Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised in the previews):

- field        the label/input/hint/error wrapper with baked-in
             aria-invalid + aria-describedby wiring (what
             f.ui_text_field renders);
- checkbox     an accessible labelled checkbox with an optional hint;
- radio        a labelled radio (grouped by name in a fieldset);
- switch       a role="switch" toggle;
- input_group  a text input with an optional prefix/suffix affix.

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



148
149
150
151
152
153
154
155
# File 'lib/generators/seams/design/design_generator.rb', line 148

def create_form_components
  %w[field checkbox radio switch input_group].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_guide ⇒ Object

The living gallery (dev/test only). The controller renders every component from its preview so the docs cannot drift; the route is guarded to Rails.env.local? both in the controller (404 in production) and at the host routes (drawn inside an if Rails.env.local? block). Ported from quire-saas's compositor guide. Eject-aware so a host can restyle the gallery chrome.



308
309
310
311
312
313
314
315
# File 'lib/generators/seams/design/design_generator.rb', line 308

def create_guide
  template "app/controllers/design/guide_controller.rb.tt",
           engine_path("app/controllers/design/guide_controller.rb")
  template_unless_ejected "app/views/layouts/design/guide.html.erb.tt",
                          engine_path("app/views/layouts/design/guide.html.erb")
  template_unless_ejected "app/views/design/guide/index.html.erb.tt",
                          engine_path("app/views/design/guide/index.html.erb")
end

#create_helper ⇒ Object

The host-wide helper module. ui_icon is the one hand-written helper; define_component_helpers! auto-derives ui_ for every preview. Ported from quire-saas's app/helpers/compositor_helper.rb.



118
119
120
121
# File 'lib/generators/seams/design/design_generator.rb', line 118

def create_helper
  template "app/helpers/design/ui_helper.rb.tt",
           engine_path("app/helpers/design/ui_helper.rb")
end

#create_icon_partials ⇒ Object

The icon sprite + icon partials — the minimum view surface the skeleton needs so render "ui/icon_sprite" (wired into the host layout below) and ui_icon resolve. #25 ships the full primitive + icon set; these two are the load-bearing pair the host layout references on first boot.



161
162
163
164
165
166
# File 'lib/generators/seams/design/design_generator.rb', line 161

def create_icon_partials
  template_unless_ejected "app/views/ui/_icon.html.erb.tt",
                          engine_path("app/views/ui/_icon.html.erb")
  template_unless_ejected "app/views/ui/_icon_sprite.html.erb.tt",
                          engine_path("app/views/ui/_icon_sprite.html.erb")
end

#create_nav_components ⇒ Object

The Navigation component set (Phase 2, GROUPKEY = nav). Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised in the previews), each carrying its baked-in navigation accessibility roles/aria:

- breadcrumb   a nav[aria-label=Breadcrumb] trail with aria-current=page;
- pagination   a nav[aria-label=Pagination] with per-page aria-current;
- menu         a role=menu list of role=menuitem links/buttons;
- segmented    a role=group of aria-pressed toggle buttons;
- stepper      an ordered list with aria-current=step + done ticks;
- toolbar      a role=toolbar of labelled icon/text buttons;
- outline      a nav[aria-label=Outline] heading tree with aria-current.

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



202
203
204
205
206
207
208
209
# File 'lib/generators/seams/design/design_generator.rb', line 202

def create_nav_components
  %w[breadcrumb pagination menu segmented stepper toolbar outline].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_overlays_components ⇒ Object

The Overlays component set (Phase 2, GROUPKEY = overlays). Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, compositor-dialog controller -> ui-dialog, quire copy neutralised in the previews), each carrying its baked-in overlay accessibility:

- dialog     a native <dialog aria-labelledby> with a labelled close
           button, driven by a ui-dialog Stimulus controller the host
           supplies (data-controller / data-action wiring baked in);
- drawer     an <aside aria-label> side-panel landmark;
- popover    a role=note annotation bubble;
- savestate  a role=status live region (saved / saving / unsaved).

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



226
227
228
229
230
231
232
233
# File 'lib/generators/seams/design/design_generator.rb', line 226

def create_overlays_components
  %w[dialog drawer popover savestate].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_primitive_components ⇒ Object

The Primitives & icons component set (Phase 2, GROUPKEY = primitives). The icon + icon_sprite primitives ship from create_icon_partials above; this set adds the remaining low-level building blocks, ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised in the previews), each carrying its baked-in accessibility:

- panel   a plain raised content surface (a content-block wrapper);
- diff    a per-line add/del/ctx list whose +/- signs are aria-labelled
        "added"/"removed" so the glyph alone is not load-bearing;
- empty   an empty-state with a required title + content-block body.

Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate.



249
250
251
252
253
254
255
256
# File 'lib/generators/seams/design/design_generator.rb', line 249

def create_primitive_components
  %w[panel diff empty].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_runtime_spec ⇒ Object



335
336
337
338
339
340
341
342
343
344
# File 'lib/generators/seams/design/design_generator.rb', line 335

def create_runtime_spec
  template "spec/runtime/design_boot_spec.rb.tt",
           engine_path("spec/runtime/design_boot_spec.rb")
  template "spec/runtime/ui_components_spec.rb.tt",
           engine_path("spec/runtime/ui_components_spec.rb")
  template "spec/runtime/form_builder_spec.rb.tt",
           engine_path("spec/runtime/form_builder_spec.rb")
  template "spec/runtime/guide_spec.rb.tt",
           engine_path("spec/runtime/guide_spec.rb")
end

#create_seed_components ⇒ Object

The seed component set — enough for the gallery + the contract/render tests to have something real to render. Ported faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised): _button (takes a content block + variant/size) and _tag (a required label: strict local — the contract test relies on it being required). Each ships with a companion preview, which is what makes it "public": the auto-wire derives ui_ from the preview, and the gallery lists it. Eject-aware so a host can own a component without losing it on regenerate. #21+ ship the full component set.



177
178
179
180
181
182
183
184
# File 'lib/generators/seams/design/design_generator.rb', line 177

def create_seed_components
  %w[button tag].each do |name|
    template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/_#{name}.html.erb")
    template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
                            engine_path("app/views/ui/previews/_#{name}.html.erb")
  end
end

#create_shell ⇒ Object

The opt-in app shell (#26), generated ONLY with --shell. Without the flag none of these files appear and the host keeps its rails-new layout.

- app/views/layouts/application.html.erb — the HOST's default layout,
overwritten (force) with one built entirely from ui_* components
(header, nav, flash banners, footer). Eject-aware so a host that has
already customised it on a later run keeps its version.
- the starter signed-in dashboard controller + view, shipped INTO the
engine (Design::DashboardController subclasses the host's
ApplicationController), with an empty-state listing the engines the
host could add. The route + root are drawn in wire_into_host.


361
362
363
364
365
366
367
368
369
370
# File 'lib/generators/seams/design/design_generator.rb', line 361

def create_shell
  return unless shell?

  say "  shell   generating the opt-in app shell (--shell)", :green
  create_shell_layout
  template "app/controllers/design/dashboard_controller.rb.tt",
           engine_path("app/controllers/design/dashboard_controller.rb")
  template_unless_ejected "app/views/design/dashboard/index.html.erb.tt",
                          engine_path("app/views/design/dashboard/index.html.erb")
end

#overwrite_engine_entry_point ⇒ Object

The base EngineGenerator emits an ISOLATED engine. The design engine is non-isolated by design (D4), so overwrite lib/design/engine.rb with the non-isolated form that auto-wires the helper into ActionController::Base. engine.rb stays framework-managed (NOT eject-aware), like every other canonical generator's engine.rb.



76
77
78
79
# File 'lib/generators/seams/design/design_generator.rb', line 76

def overwrite_engine_entry_point
  template "lib/engine.rb.tt", engine_path("lib/design/engine.rb"), force: true
  template "lib/design.rb.tt", engine_path("lib/design.rb"),        force: true
end

#overwrite_readme ⇒ Object



346
347
348
# File 'lib/generators/seams/design/design_generator.rb', line 346

def overwrite_readme
  template "README.md.tt", engine_path("README.md"), force: true
end

#remove_isolated_leftovers ⇒ Object

The base scaffold ships an isolated engine's ApplicationController and ApplicationRecord under app/controllers/design/ and app/models/design/. A view-layer engine needs neither — its only controller is the dev-only guide (created below) and it has no models — so remove the leftovers.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/generators/seams/design/design_generator.rb', line 85

def remove_isolated_leftovers
  # config/routes.rb is KEPT (an empty `Design::Engine.routes.draw do end`)
  # so the engine stays mountable in the dummy app + host; the dev-only
  # guide route (#18) is drawn into it later.
  %w[
    app/controllers/design/application_controller.rb
    app/models/design/application_record.rb
    spec/design_spec.rb
  ].each do |relative|
    full = engine_path(relative)
    next unless File.exist?(full)

    FileUtils.rm(full)
    say "  remove  #{relative} (isolated-engine leftover)", :red
  end

  %w[app/controllers/design app/models/design].each do |relative|
    full = engine_path(relative)
    Dir.rmdir(full) if File.directory?(full) && Dir.empty?(full)
  end
end

#report_summary ⇒ Object



397
398
399
# File 'lib/generators/seams/design/design_generator.rb', line 397

def report_summary
  say report_summary_text, :green
end

#wire_into_host ⇒ Object

--- Host wiring ----------------------------------------------------------



385
386
387
388
389
390
391
392
393
394
395
# File 'lib/generators/seams/design/design_generator.rb', line 385

def wire_into_host
  # Tailwind v4 is a hard dependency (D2): the @theme token layer the
  # engine ships is Tailwind-native. Inject the gem and write the token
  # block into the host's application.css.
  host_inject_gem("tailwindcss-rails", "~> 4.0")
  inject_theme_into_host_css
  set_host_default_form_builder
  render_sprite_in_host_layout
  draw_guide_route_in_host
  draw_dashboard_route_in_host if shell?
end