janela 0.11.0 → 0.13.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +50 -27
  4. data/UPGRADING.md +57 -1
  5. data/app/assets/javascripts/janela/chart_controller.js +6 -1
  6. data/app/assets/javascripts/janela/frame_controller.js +19 -9
  7. data/app/assets/stylesheets/janela.css +23 -1
  8. data/app/assets/stylesheets/vitral.css +21 -0
  9. data/app/controllers/janela/panes_controller.rb +2 -2
  10. data/app/controllers/janela/queries_controller.rb +3 -0
  11. data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
  12. data/app/helpers/janela/frames_helper.rb +12 -4
  13. data/app/models/janela/pane.rb +32 -1
  14. data/app/models/janela/query.rb +116 -4
  15. data/app/views/janela/panes/_form.html.erb +25 -0
  16. data/app/views/janela/queries/_query.html.erb +36 -14
  17. data/config/locales/en.yml +19 -0
  18. data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
  19. data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
  20. data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
  21. data/docs/agents.md +97 -0
  22. data/docs/composing.md +14 -14
  23. data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
  24. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
  25. data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
  26. data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
  27. data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
  28. data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
  29. data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
  30. data/docs/decisions/052-how-the-project-is-run.md +93 -0
  31. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
  32. data/docs/decisions/INDEX.md +27 -20
  33. data/docs/multi-tenancy.md +22 -4
  34. data/docs/roadmap.md +34 -35
  35. data/docs/theming.md +10 -4
  36. data/lib/janela/definition.rb +71 -4
  37. data/lib/janela/dimension.rb +7 -0
  38. data/lib/janela/doctor.rb +66 -8
  39. data/lib/janela/engine.rb +13 -1
  40. data/lib/janela/measure.rb +56 -7
  41. data/lib/janela/tools.rb +220 -0
  42. data/lib/janela/version.rb +1 -1
  43. data/lib/janela.rb +1 -0
  44. metadata +20 -9
@@ -18,7 +18,22 @@ module Janela
18
18
  # categories the same beside a legend that says they differ (ADR 046).
19
19
  SERIES = 8
20
20
 
21
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :default, :snapshot
21
+ # The steps a chart's height may take (ADR 047). Pane::HEIGHTS is the same
22
+ # range for a stored row, and a test holds the two together.
23
+ HEIGHTS = (1..5).freeze
24
+
25
+ # The steps a single value's prominence may take (ADR 050).
26
+ PROMINENCES = (1..3).freeze
27
+
28
+ # Each companion column is a query of its own, so how many a pane may run
29
+ # is bounded, as what one query may ask for is (ADR 051, ADR 025).
30
+ MAX_COMPANIONS = 3
31
+
32
+ # A column beside a table's label: a measure's formatted numbers, or a
33
+ # dimension's shared fact, by label.
34
+ Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
+
36
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :companions, :filters, :fixed, :default, :snapshot
22
37
 
23
38
  # The helper renders the turbo frame and the controller renders its
24
39
  # replacement, so both derive the id the same way from the same parameters.
@@ -28,7 +43,7 @@ module Janela
28
43
  parts.compact.join("_")
29
44
  end
30
45
 
31
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
46
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
32
47
  @definition = definition
33
48
  @title = title
34
49
  @measure = measure
@@ -42,6 +57,9 @@ module Janela
42
57
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
43
58
  @granularity = Dimension.granularity!(granularity) if granularity.present?
44
59
  @limit = definition.limit!(limit) if limit.present?
60
+ @height = height!(height) if height.present?
61
+ @prominence = prominence!(prominence) if prominence.present?
62
+ @companions = companions!(companions)
45
63
  end
46
64
 
47
65
  def model
@@ -82,6 +100,20 @@ module Janela
82
100
  !single_value? && CANVAS.include?(renderer)
83
101
  end
84
102
 
103
+ # A height means a box only where there is a canvas to fill it. A ring, a
104
+ # table and a single value ignore one, so switching a pane between
105
+ # renderers never invalidates it (ADR 047).
106
+ # Only a single value has a headline number to make more or less of. A
107
+ # table, a chart and a ring ignore one, so a pane switched between renderers
108
+ # keeps what it had (ADR 050).
109
+ def prominent?
110
+ single_value? && !prominence.nil?
111
+ end
112
+
113
+ def boxed?
114
+ chart? && !height.nil?
115
+ end
116
+
85
117
  def ring?
86
118
  !single_value? && RINGS.include?(renderer)
87
119
  end
@@ -188,9 +220,30 @@ module Janela
188
220
  # selection, which this pane shows the alternatives to (ADR 040, 043).
189
221
  on = definition.narrow(on || model.all, default) if default.present?
190
222
  on = definition.narrow(on || model.all, fixed) if fixed.present?
191
- return time_result(on) if time?
223
+ primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
224
+ @companion_columns = companions? ? fetch_companions(primary, on) : []
225
+ primary
226
+ end
227
+
228
+ # Only a table draws companion columns, and a stored pane is the record of
229
+ # a moment that holds none (ADR 051).
230
+ def companions?
231
+ renderer == "table" && !single_value? && !frozen? && companions.any?
232
+ end
233
+
234
+ # The columns beside the label, once a result has been read. Kept from the
235
+ # same read as the result, with the same scope and filters, the way a time
236
+ # pane keeps its buckets.
237
+ def companion_columns
238
+ @companion_columns || []
239
+ end
240
+
241
+ def dimension_header
242
+ dimension.to_s.humanize
243
+ end
192
244
 
193
- definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
245
+ def measure_header
246
+ measure.to_s.humanize
194
247
  end
195
248
 
196
249
  # The two filters a click on this label writes, for a time pane: the start
@@ -290,6 +343,65 @@ module Janela
290
343
  }.keys
291
344
  end
292
345
 
346
+ def companions!(value)
347
+ return [] if value.blank?
348
+ raise BadRequest, "companions must be a list of measure and dimension names, got #{value.class}" unless value.is_a?(Array) && value.all? { |each| each.is_a?(String) || each.is_a?(Symbol) }
349
+
350
+ names = value.map(&:to_sym)
351
+ raise BadRequest, "a pane may carry at most #{MAX_COMPANIONS} companions, got #{names.size}" if names.size > MAX_COMPANIONS
352
+ raise BadRequest, "companions must each be named once, got #{names.map(&:inspect).join(', ')}" unless names == names.uniq
353
+
354
+ names.each { |name| companion!(name) }
355
+ end
356
+
357
+ # Only what the model declared, by the name it declared it under: no
358
+ # column, no expression, nothing that becomes SQL (ADR 025, ADR 051).
359
+ def companion!(name)
360
+ declared = definition.measures.keys + definition.dimensions.keys
361
+ raise BadRequest, "#{name.inspect} is not a declared measure or dimension of #{model}. Declared: #{declared.join(', ')}" unless declared.include?(name)
362
+ raise BadRequest, "#{name.inspect} is this pane's own #{name == measure ? 'measure' : 'dimension'}" if name == measure || name == dimension
363
+ return if definition.measures.key?(name)
364
+
365
+ fact = definition.dimensions.fetch(name)
366
+ raise BadRequest, "#{name.inspect} is a time dimension, and a bucket is not a fact about a label" if fact.time?
367
+ raise BadRequest, "#{name.inspect} is a dimension, which a time pane has no shared fact to show" if dimension && definition.dimension!(dimension).time?
368
+ end
369
+
370
+ # Ordering and the limit belong to the primary measure. The companions are
371
+ # fetched for the labels it chose, one grouped query for each measure and
372
+ # one for all the dimensions (ADR 051).
373
+ def fetch_companions(primary, on)
374
+ keys = time? ? nil : primary.keys
375
+ facts = companions.reject { |name| definition.measures.key?(name) }
376
+ shared = facts.any? ? definition.facts(facts, by: dimension, where: applicable_filters, on: on, keys: keys) : {}
377
+
378
+ companions.map do |name|
379
+ if definition.measures.key?(name)
380
+ values = definition.query(name, by: dimension, where: applicable_filters, on: on, granularity: granularity, keys: keys)
381
+ formatted = definition.measure!(name)
382
+ Companion.new(name: name, header: name.to_s.humanize, fact: false,
383
+ cells: primary.keys.to_h { |label| [ label, formatted.format(values[label]) ] })
384
+ else
385
+ Companion.new(name: name, header: name.to_s.humanize, fact: true,
386
+ cells: primary.keys.to_h { |label| [ label, shared.dig(label, name).to_s ] })
387
+ end
388
+ end
389
+ end
390
+
391
+ def prominence!(value)
392
+ step = Integer(value.to_s, exception: false)
393
+ raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
394
+
395
+ step
396
+ end
397
+
398
+ def height!(value)
399
+ step = Integer(value.to_s, exception: false)
400
+ raise BadRequest, "height must be a whole number from #{HEIGHTS.first} to #{HEIGHTS.last}, got #{value.inspect}" unless HEIGHTS.cover?(step)
401
+
402
+ step
403
+ end
404
+
293
405
  def applicable_filters
294
406
  return filters if single_value?
295
407
 
@@ -41,6 +41,31 @@
41
41
  <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
42
  </div>
43
43
 
44
+ <%# What may sit beside the label: this model's other measures and its
45
+ categorical dimensions. The pane's own choices are left out when they are
46
+ already made (ADR 051). %>
47
+ <%
48
+ companion_choices = {
49
+ t("janela.companions.measures") => definition.measures.keys.reject { |name| name.to_s == pane.measure }.map { |name| [ name.to_s.humanize, name ] },
50
+ t("janela.companions.dimensions") => definition.dimensions.values.reject { |each| each.time? || each.name.to_s == pane.dimension }.map { |each| [ each.name.to_s.humanize, each.name ] }
51
+ }
52
+ %>
53
+ <div class="janela-field">
54
+ <%= form.label :companions %>
55
+ <%= form.select :companions, companion_choices, {}, multiple: true %>
56
+ <span class="janela-hint"><%= t("janela.companions.hint") %></span>
57
+ </div>
58
+
59
+ <div class="janela-field">
60
+ <%= form.label :height %>
61
+ <%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
62
+ </div>
63
+
64
+ <div class="janela-field">
65
+ <%= form.label :prominence %>
66
+ <%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
67
+ </div>
68
+
44
69
  <div class="janela-field">
45
70
  <%= form.label :span %>
46
71
  <%= form.select :span, Janela::Pane::SPANS.to_a %>
@@ -1,10 +1,10 @@
1
1
  <%# One pane's content, without its turbo frame: the same markup whether the
2
2
  pane came from a URL, a row, or a frame rendered inline. %>
3
3
  <% if query.single_value? %>
4
- <p class="janela-pane janela-value">
4
+ <%= tag.p class: [ "janela-pane", "janela-value", ("janela-prominence-#{query.prominence}" if query.prominent?) ] do %>
5
5
  <span class="janela-value-label"><%= query.title %></span>
6
6
  <strong class="janela-value-number"><%= query.format(result || 0) %></strong>
7
- </p>
7
+ <% end %>
8
8
  <% elsif result.empty? %>
9
9
  <p class="janela-pane janela-empty"><%= query.title %>: no data</p>
10
10
  <% elsif query.ring? && query.ringable?(result) %>
@@ -13,22 +13,41 @@
13
13
  <% title_id = "#{query.turbo_frame_id}-title" %>
14
14
  <figure class="janela-pane">
15
15
  <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
16
- <canvas class="janela-chart"
17
- data-controller="janela--chart"
18
- data-action="janela--chart:toggle->janela--frame#toggle"
19
- data-janela--chart-type-value="<%= query.renderer %>"
20
- data-janela--chart-title-value="<%= query.title %>"
21
- data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
22
- data-janela--chart-labels-value="<%= result.keys.to_json %>"
23
- data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
24
- data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
25
- data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
26
- <%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
27
- role="img" aria-labelledby="<%= title_id %>"></canvas>
16
+ <%# A height puts the canvas in a box of fixed size for the chart to fill;
17
+ with none there is no box and the chart is what it always was (ADR 047). %>
18
+ <% canvas = tag.canvas(
19
+ class: "janela-chart", role: "img",
20
+ aria: { labelledby: title_id, description: (t("janela.time.click_hint") if query.time? && query.clickable?) },
21
+ data: {
22
+ controller: "janela--chart",
23
+ action: "janela--chart:toggle->janela--frame#toggle",
24
+ "janela--chart-type-value": query.renderer,
25
+ "janela--chart-title-value": query.title,
26
+ "janela--chart-selected-value": query.selected_values.to_json,
27
+ "janela--chart-labels-value": result.keys.to_json,
28
+ "janela--chart-values-value": result.values.map(&:to_f).to_json,
29
+ "janela--chart-formatted-value": result.values.map { |measured| query.format(measured) }.to_json,
30
+ "janela--chart-filters-value": query.filters_for(result.keys).to_json,
31
+ "janela--chart-fixed-height-value": (true if query.boxed?)
32
+ }) %>
33
+ <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
28
34
  </figure>
29
35
  <% else %>
30
36
  <%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
31
37
  <caption><%= query.title %></caption>
38
+ <%# A header row only when there are companions to name: two columns read
39
+ without one, and a table with none is what it always was (ADR 051). %>
40
+ <% if query.companion_columns.any? %>
41
+ <thead>
42
+ <tr>
43
+ <th scope="col"><%= query.dimension_header %></th>
44
+ <th scope="col"><%= query.measure_header %></th>
45
+ <% query.companion_columns.each do |column| %>
46
+ <%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
47
+ <% end %>
48
+ </tr>
49
+ </thead>
50
+ <% end %>
32
51
  <tbody>
33
52
  <% result.each do |label, measured| %>
34
53
  <% click = query.click_data(label) if query.clickable? %>
@@ -42,6 +61,9 @@
42
61
  <% end %>
43
62
  </td>
44
63
  <td><%= query.format(measured) %></td>
64
+ <% query.companion_columns.each do |column| %>
65
+ <%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
66
+ <% end %>
45
67
  </tr>
46
68
  <% end %>
47
69
  </tbody>
@@ -20,6 +20,9 @@ en:
20
20
  granularity: Granularity
21
21
  limit: Rows
22
22
  span: Width
23
+ height: Height
24
+ prominence: Prominence
25
+ companions: Beside the label
23
26
  title: Title
24
27
  janela:
25
28
  actions:
@@ -64,6 +67,22 @@ en:
64
67
  text: Text
65
68
  link_hint: "A path on this site, such as /orders. Makes the heading a link."
66
69
  title_placeholder: Janela writes one if you leave this blank
70
+ companions:
71
+ measures: Measures
72
+ dimensions: Dimensions
73
+ hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
74
+ prominences:
75
+ automatic: Automatic
76
+ "1": Footnote
77
+ "2": Normal
78
+ "3": Hero
79
+ heights:
80
+ automatic: Automatic
81
+ "1": Very short
82
+ "2": Short
83
+ "3": Medium
84
+ "4": Tall
85
+ "5": Very tall
67
86
  renderers:
68
87
  bar: Bar chart
69
88
  doughnut: Doughnut chart
@@ -0,0 +1,7 @@
1
+ class AddHeightToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How tall a bar or line chart is drawn, one of five steps (ADR 047). Null
4
+ # is what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :height, :integer
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddProminenceToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How prominent a single value is drawn, one of three steps (ADR 050).
4
+ # Null is what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :prominence, :integer
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ class AddCompanionsToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Measures and dimensions a table pane draws as columns beside its label
4
+ # (ADR 051), by the names the model declared them under. Null is what
5
+ # every existing pane is: none, and drawn exactly as before.
6
+ add_column :janela_panes, :companions, :json
7
+ end
8
+ end
data/docs/agents.md ADDED
@@ -0,0 +1,97 @@
1
+ ---
2
+ Topics: agents, mcp, tools, authorisation, host-integration
3
+ ---
4
+
5
+ # Giving an Agent Access to Janela
6
+
7
+ Frames and panes are rows (ADR 012), so an agent could always arrange a
8
+ dashboard by writing them from a console. This page is for giving it tools
9
+ instead: read a frame, read a pane's values, and, if you choose, add, change,
10
+ remove and move panes. The reasoning is in ADR 053.
11
+
12
+ The short version: Janela gives you the tool definitions as plain Ruby. You
13
+ build them with the answer to "what may this caller read", and you register
14
+ them with whatever MCP library your application already runs. Janela
15
+ registers nothing, detects nothing and serves nothing, because it cannot
16
+ know who is asking and your application can.
17
+
18
+ ## Building the tools
19
+
20
+ ```ruby
21
+ tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) })
22
+ ```
23
+
24
+ `scope:` is required. It takes a model class and returns a relation: the same
25
+ answer your `policy_scope` gives a Janela controller (ADR 032). Leave it out
26
+ and `Janela::Unscoped` is raised when the tools are built, because a tool with
27
+ no scope would read every row of every model. Every read the tools make passes
28
+ that relation, and frames and panes are found through it too, so a caller can
29
+ change only what it can see.
30
+
31
+ Build the tools where you know who is asking, which for an MCP server is when a
32
+ tool is called. They are cheap to construct.
33
+
34
+ ## What they do
35
+
36
+ | Tool | Does | Needs `write: true` |
37
+ | --- | --- | --- |
38
+ | `describe_vocabulary` | The models, measures, dimensions, renderers and granularities the `janela` blocks declare. An agent should offer nothing else. | no |
39
+ | `list_frames` | The frames the scope returns: id, name, owner, pane count. | no |
40
+ | `get_frame` | One frame with its panes in position order. | no |
41
+ | `read_pane` | A pane's values, through the scope. Filters are Ransack predicates on declared dimensions, bounded as a reader's are (ADR 025). | no |
42
+ | `add_pane` | Add a pane to the end of a frame. | yes |
43
+ | `update_pane` | Change a pane. An invalid change leaves it as it was. | yes |
44
+ | `remove_pane` | Remove a pane and close the gap. | yes |
45
+ | `move_pane` | Move a pane one place up or down. | yes |
46
+
47
+ The write tools are off unless you build the tools with `write: true`. Their
48
+ inputs are the pane's own attributes, the ones the README
49
+ names, and nothing else: an attribute that is not a pane's is refused rather
50
+ than dropped. A pane Janela would refuse is refused with its own reason, for
51
+ example `Measure "nonsense" is not a measure of Order`.
52
+
53
+ There is no tool to create a frame or take a snapshot. Your code finds or makes
54
+ a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
55
+ scope you have named (ADR 033, ADR 034).
56
+
57
+ ## Registering them
58
+
59
+ `Janela::Tools.all` is the list of definitions, and needs no scope: each has a
60
+ `name`, a `description`, an `input_schema` (JSON Schema) and `read_only`.
61
+ `tools.call(name, arguments)` runs one and returns a Hash, or raises a
62
+ `Janela::Error` (`NotFound`, `BadRequest` or `Unscoped`) that an adapter reports
63
+ to the agent as the tool's own error.
64
+
65
+ For the official Ruby SDK, the `mcp` gem, an adapter looks like this. It is run
66
+ by Janela's own tests, so it cannot drift from the code. `server_context` is
67
+ whatever your server was built with, and is where you keep the caller; the
68
+ scope is built from it at the moment of the call.
69
+
70
+ ```ruby
71
+ def janela_server(write: false)
72
+ tools = Janela::Tools.all(write: write).map do |tool|
73
+ MCP::Tool.define(name: tool.name, description: tool.description, input_schema: tool.input_schema,
74
+ annotations: { read_only_hint: tool.read_only }) do |server_context:, **arguments|
75
+ # The scope is built per call, because who is asking is known only now.
76
+ scope = ->(model) { server_context[:scope].call(model) }
77
+ result = Janela::Tools.new(scope: scope, write: write).call(tool.name, arguments)
78
+ MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
79
+ rescue Janela::Error => error
80
+ MCP::Tool::Response.new([ { type: "text", text: error.message } ], error: true)
81
+ end
82
+ end
83
+
84
+ MCP::Server.new(name: "janela", tools: tools, server_context: { scope: yield })
85
+ end
86
+ ```
87
+
88
+ `mcp` is not a dependency of Janela, and a host using another library writes the
89
+ same few lines against it.
90
+
91
+ ## What Janela does not do
92
+
93
+ It does not serve MCP, detect a server, edit your configuration or install
94
+ anything. It holds no credentials and has no idea who your agent is. Whether an
95
+ agent may reach your MCP server at all is your server's authentication, which
96
+ Janela never sees. Everything it adds is the question it already asks: what may
97
+ this caller read?
data/docs/composing.md CHANGED
@@ -23,12 +23,12 @@ with your own classes plus the hooks in [Theming Janela](theming).
23
23
  | `janela_frame do ... end` | anywhere inside the block | the page needs a heading, text, links or a layout of its own |
24
24
  | `janela_frame @frame` | none | the dashboard is data an analyst edits without a deploy |
25
25
 
26
- A stored frame renders panes and nothing else. That is deliberate (ADR
27
- 012): a row names a measure the model declared, so an analyst arranges
28
- what is shown and cannot put arbitrary content on the page. If a
29
- dashboard needs a heading and an explanation, write them in the page
30
- around the frame, or use the block form. Whether a stored frame should
31
- hold content of its own, such as text or an image, has not been decided.
26
+ A stored frame renders panes, and a pane is either a query, a few words
27
+ the analyst writes (a heading, a sentence, a link), or a partial you
28
+ wrote and they place by name (ADR 039). What an analyst writes is
29
+ escaped, never markup, so a row cannot put arbitrary content on the page
30
+ (ADR 012). Anything richer than that, an icon, an image, a layout of your
31
+ own, goes in the page around the frame, or use the block form.
32
32
 
33
33
  ```erb
34
34
  <h2>Orders</h2>
@@ -58,9 +58,9 @@ same classes a stored frame gets from its integers:
58
58
 
59
59
  Every direct child is a grid item, your own elements included, so a
60
60
  heading can take a whole row and a text box can sit between two panes.
61
- `janela_pane` takes no class of its own yet
62
- ([#29](https://github.com/retail-tasker/janela/issues/29)), so wrap a
63
- pane to span it:
61
+ `janela_pane` takes no class of its own, and will not: arranging panes
62
+ beyond the shipped grid is the host's (see the roadmap). So wrap a pane
63
+ to span it:
64
64
 
65
65
  ```erb
66
66
  <div class="janela-span-2"><%= janela_pane Order, :revenue, by: :status, as: :bar %></div>
@@ -195,11 +195,11 @@ and number, so the figure needs a `<div>` rather than a `<p>` around it,
195
195
  and a line of CSS to put the numbers side by side. Without it they
196
196
  stack.
197
197
 
198
- A done out of total figure cannot be two panes yet. A measure has no
199
- condition of its own, so there is no pane for the done half. Compute it
200
- as the progress bar below does. A measure that is itself the ratio is
201
- proposed in ADR 038
202
- ([#27](https://github.com/retail-tasker/janela/issues/27)).
198
+ A done out of total figure over a yes or no column is one pane: a
199
+ `ratio:` measure is the share of rows where a boolean is true (ADR 038).
200
+ When the condition is not a column, a measure has no condition of its own,
201
+ so there is no pane for the done half; compute it as the progress bar
202
+ below does.
203
203
 
204
204
  ### A progress bar
205
205
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  Date: 2026-09-16
3
- Status: Accepted
3
+ Status: Accepted (the exclusive null group paragraph is superseded by ADR 049)
4
4
  Related: ADR 003, ADR 005, ADR 008, ADR 009, ADR 018
5
5
  Triggers:
6
6
  - changing what a click on a value does
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  Date: 2026-09-24
3
- Status: Proposed
3
+ Status: Accepted
4
4
  Related: ADR 001, ADR 002, ADR 007, ADR 020, ADR 025
5
5
  Triggers:
6
6
  - adding a measure kind, or a sixth aggregate to the DSL