janela 0.2.0 → 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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +203 -21
  4. data/UPGRADING.md +136 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +16 -8
  6. data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
  7. data/app/assets/stylesheets/janela.css +163 -0
  8. data/app/controllers/janela/application_controller.rb +45 -0
  9. data/app/controllers/janela/frames_controller.rb +57 -0
  10. data/app/controllers/janela/panes_controller.rb +66 -13
  11. data/app/controllers/janela/queries_controller.rb +17 -0
  12. data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
  13. data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
  14. data/app/models/janela/frame.rb +28 -0
  15. data/app/models/janela/pane.rb +107 -85
  16. data/app/models/janela/query.rb +149 -0
  17. data/app/models/janela/snapshot.rb +6 -6
  18. data/app/views/janela/frames/_card.html.erb +7 -0
  19. data/app/views/janela/frames/_form.html.erb +23 -0
  20. data/app/views/janela/frames/_frame.html.erb +4 -0
  21. data/app/views/janela/frames/_pane.html.erb +12 -0
  22. data/app/views/janela/frames/edit.html.erb +25 -0
  23. data/app/views/janela/frames/index.html.erb +15 -0
  24. data/app/views/janela/frames/new.html.erb +5 -0
  25. data/app/views/janela/frames/show.html.erb +9 -0
  26. data/app/views/janela/panes/_form.html.erb +58 -0
  27. data/app/views/janela/panes/_row.html.erb +12 -0
  28. data/app/views/janela/panes/edit.html.erb +5 -0
  29. data/app/views/janela/panes/new.html.erb +22 -0
  30. data/app/views/janela/panes/show.html.erb +3 -44
  31. data/app/views/janela/queries/_query.html.erb +47 -0
  32. data/app/views/janela/queries/show.html.erb +4 -0
  33. data/app/views/janela/shared/_errors.html.erb +7 -0
  34. data/app/views/layouts/janela/application.html.erb +26 -0
  35. data/config/importmap.rb +1 -1
  36. data/config/locales/en.yml +64 -0
  37. data/config/routes.rb +15 -2
  38. data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
  39. data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
  40. data/docs/decisions/001-built-to-be-forked.md +4 -0
  41. data/docs/decisions/005-pane-urls-and-mount-path.md +4 -1
  42. data/docs/decisions/009-snapshots.md +5 -2
  43. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +134 -0
  44. data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
  45. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  46. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  47. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  48. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  49. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  50. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  51. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  52. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  53. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  54. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  55. data/docs/decisions/INDEX.md +24 -5
  56. data/docs/multi-tenancy.md +175 -0
  57. data/lib/janela/definition.rb +23 -9
  58. data/lib/janela/dimension.rb +5 -1
  59. data/lib/janela/doctor.rb +219 -0
  60. data/lib/janela/engine.rb +15 -2
  61. data/lib/janela/measure.rb +68 -4
  62. data/lib/janela/version.rb +1 -1
  63. data/lib/janela.rb +24 -1
  64. data/lib/tasks/janela.rake +6 -0
  65. metadata +57 -4
@@ -1,45 +1,4 @@
1
- <%= turbo_frame_tag @pane.frame_id do %>
2
- <% if @pane.single_value? %>
3
- <p class="janela-pane janela-value">
4
- <span class="janela-value-label"><%= @pane.title %></span>
5
- <strong class="janela-value-number"><%= number_with_delimiter(@result || 0) %></strong>
6
- </p>
7
- <% elsif @result.empty? %>
8
- <p class="janela-pane janela-empty"><%= @pane.title %>: no data</p>
9
- <% elsif @pane.chart? %>
10
- <canvas class="janela-pane janela-chart"
11
- data-controller="janela--chart"
12
- data-action="janela--chart:toggle->janela--dashboard#toggle"
13
- data-janela--chart-type-value="<%= @pane.renderer %>"
14
- data-janela--chart-title-value="<%= @pane.title %>"
15
- data-janela--chart-key-value="<%= @pane.filter_key %>"
16
- data-janela--chart-selected-value="<%= @pane.selected_value %>"
17
- data-janela--chart-labels-value="<%= @result.keys.to_json %>"
18
- data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
19
- role="img" aria-label="<%= @pane.title %>"></canvas>
20
- <% else %>
21
- <table class="janela-pane">
22
- <caption><%= @pane.title %></caption>
23
- <tbody>
24
- <% @result.each do |value, measured| %>
25
- <tr>
26
- <td>
27
- <% if @pane.clickable? %>
28
- <button type="button"
29
- aria-pressed="<%= value.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
30
- data-action="janela--dashboard#toggle"
31
- data-janela--dashboard-key-param="<%= @pane.filter_key %>"
32
- data-janela--dashboard-value-param="<%= value %>">
33
- <%= value %>
34
- </button>
35
- <% else %>
36
- <span><%= value %></span>
37
- <% end %>
38
- </td>
39
- <td><%= number_with_delimiter(measured) %></td>
40
- </tr>
41
- <% end %>
42
- </tbody>
43
- </table>
44
- <% end %>
1
+ <% content_for :title, @query.title %>
2
+ <%= turbo_frame_tag @pane.turbo_frame_id do %>
3
+ <%= render "janela/queries/query", query: @query, result: @result %>
45
4
  <% end %>
@@ -0,0 +1,47 @@
1
+ <%# One pane's content, without its turbo frame: the same markup whether the
2
+ pane came from a URL, a row, or a frame rendered inline. %>
3
+ <% if query.single_value? %>
4
+ <p class="janela-pane janela-value">
5
+ <span class="janela-value-label"><%= query.title %></span>
6
+ <strong class="janela-value-number"><%= query.format(result || 0) %></strong>
7
+ </p>
8
+ <% elsif result.empty? %>
9
+ <p class="janela-pane janela-empty"><%= query.title %>: no data</p>
10
+ <% elsif query.chart? %>
11
+ <canvas class="janela-pane janela-chart"
12
+ data-controller="janela--chart"
13
+ data-action="janela--chart:toggle->janela--frame#toggle"
14
+ data-janela--chart-type-value="<%= query.renderer %>"
15
+ data-janela--chart-title-value="<%= query.title %>"
16
+ data-janela--chart-selected-value="<%= query.selected_value %>"
17
+ data-janela--chart-labels-value="<%= result.keys.to_json %>"
18
+ data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
19
+ data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
20
+ data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
21
+ role="img" aria-label="<%= query.title %>"></canvas>
22
+ <% else %>
23
+ <table class="janela-pane">
24
+ <caption><%= query.title %></caption>
25
+ <tbody>
26
+ <% result.each do |label, measured| %>
27
+ <% key, value = query.filter_params(label) %>
28
+ <tr>
29
+ <td>
30
+ <% if key %>
31
+ <button type="button"
32
+ aria-pressed="<%= label.to_s == query.selected_value.to_s && query.selected_value.present? %>"
33
+ data-action="janela--frame#toggle"
34
+ data-janela--frame-key-param="<%= key %>"
35
+ data-janela--frame-value-param="<%= value %>">
36
+ <%= label %>
37
+ </button>
38
+ <% else %>
39
+ <span><%= label %></span>
40
+ <% end %>
41
+ </td>
42
+ <td><%= query.format(measured) %></td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <% content_for :title, @query.title %>
2
+ <%= turbo_frame_tag @query.turbo_frame_id do %>
3
+ <%= render "janela/queries/query", query: @query, result: @result %>
4
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <% if record.errors.any? %>
2
+ <ul class="janela-errors">
3
+ <% record.errors.full_messages.each do |message| %>
4
+ <li><%= message %></li>
5
+ <% end %>
6
+ </ul>
7
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%# Janela's own pages and a pane opened directly, outside a Turbo Frame.
2
+ Deliberately minimal: the engine cannot know the host's asset names or
3
+ bundler, and a host layout's route helpers do not resolve inside an
4
+ isolated engine (ADR 011). It loads Janela's own stylesheet, which the gem
5
+ ships and can therefore name, and no JavaScript: Turbo and Stimulus come
6
+ from the host, so these pages are static dashboards rather than broken
7
+ ones. A host can override this file, or point the controller at its own
8
+ layout. %>
9
+ <!DOCTYPE html>
10
+ <html>
11
+ <head>
12
+ <title><%= yield(:title).presence || "Janela" %></title>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width,initial-scale=1">
15
+ <%= csrf_meta_tags %>
16
+ <%= csp_meta_tag %>
17
+ <%= stylesheet_link_tag "janela" %>
18
+ <%= yield :head %>
19
+ </head>
20
+ <body class="janela-page">
21
+ <% flash.each do |_kind, message| %>
22
+ <p class="janela-flash"><%= message %></p>
23
+ <% end %>
24
+ <%= yield %>
25
+ </body>
26
+ </html>
data/config/importmap.rb CHANGED
@@ -1,4 +1,4 @@
1
- pin "janela/dashboard_controller", to: "janela/dashboard_controller.js"
1
+ pin "janela/frame_controller", to: "janela/frame_controller.js"
2
2
  pin "janela/chart_controller", to: "janela/chart_controller.js"
3
3
 
4
4
  # Hosts that already pin their own Chart.js keep it.
@@ -0,0 +1,64 @@
1
+ en:
2
+ activerecord:
3
+ models:
4
+ janela/frame:
5
+ one: Frame
6
+ other: Frames
7
+ janela/pane:
8
+ one: Pane
9
+ other: Panes
10
+ attributes:
11
+ janela/frame:
12
+ name: Name
13
+ columns: Columns
14
+ gap: Gap
15
+ janela/pane:
16
+ model: Model
17
+ measure: Measure
18
+ dimension: Dimension
19
+ renderer: Renderer
20
+ granularity: Granularity
21
+ limit: Rows
22
+ span: Width
23
+ title: Title
24
+ janela:
25
+ actions:
26
+ add_pane: "Add a %{name}"
27
+ cancel: Cancel
28
+ continue: Continue
29
+ delete: Remove
30
+ delete_frame: "Delete this %{name}"
31
+ edit: Edit
32
+ move_down: Down
33
+ move_up: Up
34
+ new_frame: "New %{name}"
35
+ save: Save
36
+ view: View
37
+ frames:
38
+ created: Created.
39
+ destroyed: Deleted.
40
+ updated: Saved.
41
+ index:
42
+ blank: "No %{name} yet."
43
+ edit:
44
+ blank: "No %{name} yet."
45
+ frame:
46
+ panes:
47
+ zero: no panes
48
+ one: 1 pane
49
+ other: "%{count} panes"
50
+ changed: "changed %{time} ago"
51
+ panes:
52
+ created: Added.
53
+ destroyed: Removed.
54
+ updated: Saved.
55
+ declared_granularity: As the model declares it
56
+ granularity_hint: "Applies to %{dimensions} only."
57
+ no_dimension: "No breakdown, one number"
58
+ no_limit: Every row
59
+ summary: "%{renderer}, %{span} wide"
60
+ title_placeholder: Janela writes one if you leave this blank
61
+ renderers:
62
+ bar: Bar chart
63
+ line: Line chart
64
+ table: Table
data/config/routes.rb CHANGED
@@ -1,9 +1,22 @@
1
1
  Janela::Engine.routes.draw do
2
2
  segment = /[a-z0-9_]+/
3
3
 
4
- get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_panes#show", as: :snapshot_pane,
4
+ # Frames sit at the mount root and are drawn before the pane grammar, with a
5
+ # numeric constraint: no model's route key is all digits, so /3 is a frame
6
+ # and /orders/revenue is a pane, with nothing to disambiguate (ADR 014).
7
+ resources :frames, path: "", constraints: { id: /\d+/ } do
8
+ # No index: a frame's own edit page is the list of its panes.
9
+ resources :panes, except: :index, constraints: { id: /\d+/, frame_id: /\d+/ } do
10
+ member do
11
+ patch :move_up
12
+ patch :move_down
13
+ end
14
+ end
15
+ end
16
+
17
+ get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_queries#show", as: :snapshot_pane,
5
18
  constraints: { snapshot_id: /\d+/, model: segment, measure: segment, dimension: segment }
6
19
 
7
- get ":model/:measure(/:dimension)", to: "panes#show", as: :pane,
20
+ get ":model/:measure(/:dimension)", to: "queries#show", as: :pane,
8
21
  constraints: { model: segment, measure: segment, dimension: segment }
9
22
  end
@@ -0,0 +1,13 @@
1
+ class CreateJanelaFrames < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :janela_frames do |t|
4
+ t.string :name, null: false
5
+ t.integer :columns, null: false, default: 3
6
+ t.integer :gap, null: false, default: 4
7
+ # Janela never reads the owner. It is here so a host's Pundit Scope has
8
+ # something to filter a frame on (ADR 014).
9
+ t.references :owner, polymorphic: true, null: true
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ class CreateJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :janela_panes do |t|
4
+ # The composite index below is the one a frame's panes are read by, so
5
+ # references adds none of its own.
6
+ t.references :frame, null: false, index: false, foreign_key: { to_table: :janela_frames }
7
+ t.integer :position, null: false
8
+ t.integer :span, null: false, default: 1
9
+ # A model's route key, the same identifier a pane URL carries (ADR 005).
10
+ t.string :model, null: false
11
+ t.string :measure, null: false
12
+ t.string :dimension
13
+ t.string :renderer, null: false, default: "table"
14
+ t.string :granularity
15
+ t.integer :limit
16
+ t.string :title
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :janela_panes, [ :frame_id, :position ]
21
+ end
22
+ end
@@ -71,3 +71,7 @@ Janela is designed to be forked, not just configured.
71
71
  special-casing Janela itself.
72
72
  - Future scope decisions (what's in the 5%, what isn't) should cite
73
73
  this ADR rather than re-litigate the philosophy each time.
74
+ - The exclusion of a report designer was partly superseded by ADR 012:
75
+ composition became data, because this ADR assumed the developer
76
+ authored dashboards and the author is the analyst. The reasoning
77
+ still holds for the query vocabulary, which stays in code.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  Date: 2026-09-15
3
3
  Status: Accepted
4
- Related: ADR 001, ADR 002, ADR 003, ADR 004
4
+ Related: ADR 001, ADR 002, ADR 003, ADR 004, ADR 011
5
5
  Triggers:
6
6
  - changing the URL of a pane or how a dashboard frame addresses one
7
7
  - mounting the engine anywhere other than the default, or with as:
@@ -134,3 +134,6 @@ author reached for unprompted.
134
134
  - Looking up the mount in the host's route table means the helper
135
135
  finds the first mount of the engine. Mounting Janela twice is
136
136
  unsupported and not a goal.
137
+ - The claim above that a directly opened pane renders "inside the
138
+ host's layout" was wrong and is superseded by ADR 011: a host
139
+ layout's route helpers cannot resolve inside an isolated engine.
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  Date: 2026-09-15
3
3
  Status: Accepted
4
- Related: ADR 001, ADR 002, ADR 005, ADR 008
4
+ Related: ADR 001, ADR 002, ADR 005, ADR 008, ADR 012
5
+ Superseded in part by: ADR 012
5
6
  Triggers:
6
7
  - publishing a dashboard or pane for an audience that must not see live data or slicers
7
8
  - adding a database table, migration or model to the engine
@@ -53,7 +54,9 @@ several panes frozen at the same instant under the same filters, so a
53
54
  `Janela::Snapshot` row holds a name, `taken_at`, the filters, and an
54
55
  array of pane results. There is no Dashboard model and no dashboard
55
56
  DSL: the host's page is the dashboard, and the host names the panes to
56
- freeze. If the host renders its live page from the same list it passes
57
+ freeze. (Superseded in part by ADR 012: a dashboard is now a record,
58
+ `Janela::Frame`, and a snapshot of a frame rather than of a list is
59
+ the obvious next shape. Nothing about what a snapshot stores changes.) If the host renders its live page from the same list it passes
57
60
  here, nothing is declared twice.
58
61
 
59
62
  ```ruby
@@ -0,0 +1,134 @@
1
+ ---
2
+ Date: 2026-09-15
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 005, ADR 009, ADR 012, ADR 014
5
+ Superseded in part by: ADR 012
6
+ Triggers:
7
+ - writing or changing guidance for AI agents about using Janela
8
+ - proposing that Janela ship an agent, a skill or an MCP surface to hosts
9
+ - adding generators or introspection tasks
10
+ - changing the public API in a way the guidance describes
11
+ - deciding what a host application receives on install beyond the code
12
+ Topics: ai, agents, jan, skills, documentation, install, dx, scope
13
+ ---
14
+
15
+ # ADR 010: Agent Guidance Ships, the Agent Waits
16
+
17
+ ## Context
18
+
19
+ ADR 001 decided the reasoning behind Janela ships inside the gem, as
20
+ ADRs, so a developer or their agent understands why a piece exists
21
+ before changing it. That covers the *why*. Nothing covers the *how*,
22
+ and the first real installation showed the gap has a price: three
23
+ traps bit within an hour, each a one line fix found only by debugging.
24
+ Ransack's allowlist is per class, so a `through:` dimension needs the
25
+ associated model to allow the attribute. A host that bundles with
26
+ esbuild has no importmap, so none of Janela's JavaScript loads and
27
+ nothing cross filters while everything looks right. Janela's
28
+ controllers are exactly as authenticated as the host's
29
+ `ApplicationController`, which in a host that authenticates per
30
+ controller means not at all.
31
+
32
+ None of that is derivable from reading the code, which is precisely
33
+ the test ADR 001 sets for what belongs in the repository as prose.
34
+
35
+ A second, larger idea came up alongside it: that Janela ship an agent
36
+ of its own, named Jan, so installing the gem gives a host a briefed
37
+ colleague rather than a briefing. Open source has always shipped code
38
+ and documentation; it could now ship the person who knows how to use
39
+ them. It is a genuinely novel idea and nobody is doing it.
40
+
41
+ The two are not the same commitment, and this ADR separates them.
42
+
43
+ ## Decision
44
+
45
+ **A skill ships with the gem and installs into the host.**
46
+ `docs/skills/janela/SKILL.md` lives in the gem's `docs/` tree beside
47
+ the ADRs. `rails janela:install:skill` copies it to the host's
48
+ `.claude/skills/janela/` and prints the one line to add to an
49
+ `AGENTS.md` or `CLAUDE.md` for tools that read those instead. Janela
50
+ never edits a host's instruction files itself.
51
+
52
+ The skill is knowledge, not instructions to a particular agent, which
53
+ is why it survives whatever agent formats come and go.
54
+
55
+ **What the skill covers, in this order:**
56
+
57
+ 1. The dashboard shape that works, and why: a row of single value
58
+ panes, a time series, then categorical breakdowns.
59
+ 2. Choosing measures and dimensions. Name them for what they mean and
60
+ alias through dimensions. Keep categories low cardinality or pass
61
+ `limit`. Declare time dimensions with the granularity people
62
+ actually ask about. A dimension declaration is a promise that
63
+ filtering on it is allowed.
64
+ 3. The naming table from ADR 005, so an analyst's sentence becomes a
65
+ pane URL and back.
66
+ 4. When to take a snapshot, and that a snapshot holds results, not
67
+ HTML.
68
+ 5. The three traps above, each with its one line fix.
69
+ 6. What Janela deliberately does not do, so an agent does not build
70
+ natural language query, row level security or a scheduler into the
71
+ host by accident. Composition is no longer on that list: ADR 012
72
+ made frames and panes records and ADR 014 sequenced the editing
73
+ surfaces, so the skill points an agent at those rather than telling
74
+ it to compose dashboards in ERB.
75
+
76
+ **The skill describes the README's API and nothing else.** One API,
77
+ one set of names. If the skill needs to say something the README does
78
+ not, the README is incomplete and gets fixed first. No agent only
79
+ vocabulary.
80
+
81
+ **The demo application is the worked example.** `test/dummy` shows
82
+ every construct on realistic data, deployed and clickable. The skill
83
+ points at specific files rather than duplicating them.
84
+
85
+ **Every code sample is executable.** Each sample in the skill and in
86
+ `docs/guides/` is lifted from a file the test suite exercises, or is
87
+ run by a test. A sample that cannot be run is written as a sentence,
88
+ not a code block. This is the mechanism that stops the guidance
89
+ drifting from the code.
90
+
91
+ **Jan is not shipped to hosts yet.** An agent definition is added to
92
+ this repository only, as the project's own collaborator, and is not
93
+ copied into host applications by any install task. Three reasons:
94
+
95
+ - Its method, read the models, declare dimensions, compose in the
96
+ standard shape, wire the install, verify in a browser, is what a
97
+ capable agent with the skill loaded already does. The marginal value
98
+ over the skill is a name to invoke and a guarantee the skill is
99
+ loaded. That is convenience, not capability, and convenience is not
100
+ worth a public surface.
101
+ - Agent definition formats are unsettled. The skill's content is prose
102
+ about dashboards and survives any format; an agent definition is the
103
+ part most likely to be stale in six months.
104
+ - A gem that writes a named agent into a host arrives with opinions
105
+ about how that team works, not only about what its code does. That
106
+ cuts against the posture in ADR 001, where the invitation is to read
107
+ the code and fork it.
108
+
109
+ **What would change this.** Ship Jan to hosts when either is true:
110
+
111
+ - Jan, used on this repository, demonstrably does something a skill
112
+ loaded agent does not. Name the thing in the ADR that supersedes
113
+ this one.
114
+ - An MCP surface exists. An agent that can query dashboards and take
115
+ snapshots through tools has a job no skill can do, and at that point
116
+ a named agent stops being a wrapper and becomes a user.
117
+
118
+ ## Consequences
119
+
120
+ - A host that installs Janela and runs one task gets an opinionated,
121
+ current briefing, including the three traps that cost real debugging
122
+ time. That is the concrete value of this layer and it lands now.
123
+ - `docs/guides/` becomes a real directory shipping in the gem, so a
124
+ forker receives reasoning, rules and how to together.
125
+ - Jan exists but only here. The agent that helps build the gem is the
126
+ agent that would one day help hosts, so drift between what Jan says
127
+ and what the code does surfaces in this repository first. That is
128
+ the trial period, and it is free.
129
+ - Saying no to shipping Jan is recorded rather than remembered, with
130
+ the conditions that would reverse it. A future proposal cites this
131
+ ADR instead of relitigating the idea.
132
+ - Introspection (`rails janela:describe Model`), generators and MCP
133
+ are each one ADR away. Each must be a wrapper over the existing API,
134
+ for the same reason the skill must describe only one.
@@ -0,0 +1,81 @@
1
+ ---
2
+ Date: 2026-09-15
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005
5
+ Triggers:
6
+ - changing which layout a pane renders in
7
+ - a host reporting NameError from its own layout when a pane loads
8
+ - adding anything to the engine's own layout
9
+ - making the dummy application unrepresentative of a real host
10
+ Topics: layouts, engines, panes, urls, install
11
+ ---
12
+
13
+ # ADR 011: Panes Do Not Render in the Host Layout
14
+
15
+ ## Context
16
+
17
+ ADR 005 promised that a pane opened directly "gets that pane alone
18
+ inside the host's layout". Dogfooding 0.2.0 in a second application
19
+ showed that promise is a bug. `Janela::ApplicationController` inherits
20
+ the host's `ApplicationController` and therefore the host's layout,
21
+ but the engine is `isolate_namespace`d, so a route helper written in
22
+ that layout resolves against Janela's routes and raises `NameError`.
23
+ Almost every real application layout has a nav, so almost every host
24
+ is broken on install, and a whole dashboard of frames goes blank.
25
+
26
+ The gem's own dummy application has no route helper in its layout,
27
+ which is exactly why the suite, the demo and the first host install
28
+ never saw it. The first host squeaked through because its only layout
29
+ helpers were an Active Storage URL and a PWA manifest path rather
30
+ than nav links.
31
+
32
+ ## Decision
33
+
34
+ **A pane requested inside a Turbo Frame renders with no layout.**
35
+ Turbo extracts the matching frame from the response and discards
36
+ everything around it, so a layout was never doing any work there. This
37
+ alone fixes every dashboard, which is how panes are almost always
38
+ requested.
39
+
40
+ **A pane requested directly renders in Janela's own layout.** A
41
+ minimal layout in the engine, carrying only a charset, a viewport, the
42
+ CSRF and CSP tags and a `yield`. It keeps ADR 005's shareable pane
43
+ alive and cannot depend on anything the host has not got.
44
+
45
+ ```ruby
46
+ layout -> { turbo_frame_request? ? false : "janela/application" }
47
+ ```
48
+
49
+ **A directly opened pane is therefore unstyled, and that is
50
+ documented.** Janela ships no CSS (issue #15), and the engine layout
51
+ deliberately loads none of the host's assets, because it cannot know
52
+ their names or whether the host bundles or uses importmap. A host that
53
+ wants its own styling on direct pane URLs sets
54
+ `Janela::ApplicationController.layout "application"` in an
55
+ initializer, and is told the condition: that layout must not call a
56
+ bare host route helper, since inside an engine those need a
57
+ `main_app.` prefix.
58
+
59
+ **The dummy application's layout gains a route helper.** The dummy is
60
+ the gem's stand in for a real host, and a stand in that omits the most
61
+ common thing a layout contains is not doing its job. A `link_to` to
62
+ the dashboard makes the suite fail if a pane ever renders in the host
63
+ layout again.
64
+
65
+ ## Consequences
66
+
67
+ - ADR 005's sentence about the host layout is superseded by this ADR.
68
+ The rest of ADR 005, the URL grammar and the naming table, stands.
69
+ - Direct pane URLs render a table but not a chart, because no
70
+ JavaScript is loaded. Shareable pane links are therefore honest for
71
+ data and plain for visuals until a host opts its own layout in.
72
+ Acceptable: the pane URL's job is to show a number to someone, and
73
+ the dashboard is where charts live.
74
+ - The engine now owns a view that a host might want to override.
75
+ `app/views/layouts/janela/application.html.erb` is overridable by
76
+ the usual Rails precedence, which is the Rails answer and needs no
77
+ configuration of Janela's own.
78
+ - The lesson generalises beyond layouts: the dummy application should
79
+ resemble a real host in the ways hosts actually vary. Each time a
80
+ host finds something the dummy could not, the dummy gains that
81
+ characteristic rather than the fix being verified only by hand.