janela 0.12.0 → 0.14.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.
@@ -66,6 +66,12 @@
66
66
  <%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
67
67
  </div>
68
68
 
69
+ <div class="janela-field">
70
+ <%= form.check_box :value_labels %>
71
+ <%= form.label :value_labels %>
72
+ <span class="janela-hint"><%= t("janela.value_labels.hint") %></span>
73
+ </div>
74
+
69
75
  <div class="janela-field">
70
76
  <%= form.label :span %>
71
77
  <%= form.select :span, Janela::Pane::SPANS.to_a %>
@@ -15,21 +15,23 @@
15
15
  <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
16
16
  <%# A height puts the canvas in a box of fixed size for the chart to fill;
17
17
  with none there is no box and the chart is what it always was (ADR 047). %>
18
- <% canvas = capture do %>
19
- <canvas class="janela-chart"
20
- data-controller="janela--chart"
21
- data-action="janela--chart:toggle->janela--frame#toggle"
22
- data-janela--chart-type-value="<%= query.renderer %>"
23
- data-janela--chart-title-value="<%= query.title %>"
24
- data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
25
- data-janela--chart-labels-value="<%= result.keys.to_json %>"
26
- data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
27
- data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
28
- data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
29
- <%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
30
- <%= "data-janela--chart-fixed-height-value=true".html_safe if query.boxed? %>
31
- role="img" aria-labelledby="<%= title_id %>"></canvas>
32
- <% end %>
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-tick-format-value": query.tick_format.to_json,
31
+ "janela--chart-filters-value": query.filters_for(result.keys).to_json,
32
+ "janela--chart-fixed-height-value": (true if query.boxed?),
33
+ "janela--chart-value-labels-value": (true if query.labelled?)
34
+ }) %>
33
35
  <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
34
36
  </figure>
35
37
  <% else %>
@@ -22,9 +22,13 @@ en:
22
22
  span: Width
23
23
  height: Height
24
24
  prominence: Prominence
25
+ value_labels: Show each bar's value
25
26
  companions: Beside the label
26
27
  title: Title
27
28
  janela:
29
+ errors:
30
+ not_found: There is no such pane.
31
+ bad_request: That request is not allowed on this pane.
28
32
  actions:
29
33
  add_pane: "Add a %{name}"
30
34
  cancel: Cancel
@@ -71,6 +75,8 @@ en:
71
75
  measures: Measures
72
76
  dimensions: Dimensions
73
77
  hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
78
+ value_labels:
79
+ hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
74
80
  prominences:
75
81
  automatic: Automatic
76
82
  "1": Footnote
@@ -0,0 +1,7 @@
1
+ class AddValueLabelsToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Whether a bar chart draws each bar's value on the bar (ADR 054). Null is
4
+ # what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :value_labels, :boolean
6
+ end
7
+ 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
 
@@ -2,6 +2,7 @@
2
2
  Date: 2026-09-16
3
3
  Status: Accepted
4
4
  Related: ADR 002, ADR 009, ADR 012, ADR 018
5
+ Superseded in part by: ADR 054
5
6
  Triggers:
6
7
  - a number rendering with more precision than it means
7
8
  - adding an option to a pane, a frame or a pane URL
@@ -74,7 +75,9 @@ back under a format declared today.
74
75
  and a chart tooltip all show the string the measure produced, and the
75
76
  chart is handed those strings rather than formatting a second time in
76
77
  JavaScript. The chart still plots raw numbers, because an axis is a
77
- scale and not a label.
78
+ scale and not a label. (Superseded in part by
79
+ ADR 054: the axis is formatted by the measure too. The chart still plots
80
+ raw numbers.)
78
81
 
79
82
  ## Consequences
80
83
 
@@ -0,0 +1,187 @@
1
+ ---
2
+ Date: 2026-10-02
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 010, ADR 012, ADR 014, ADR 017, ADR 019, ADR 025, ADR 032, ADR 033, ADR 034, ADR 037, ADR 041
5
+ Triggers:
6
+ - proposing an MCP server, an agent tool or any machine facing surface for Janela
7
+ - an agent needs to read or arrange a frame without a browser
8
+ - adding a dependency on an MCP library
9
+ - deciding whether Janela should detect, register or install anything in a host's own tooling
10
+ - running a Janela query outside a controller, from a tool, a job or a console
11
+ - changing what an agent may do to a pane
12
+ Topics: ai, agents, mcp, tools, authorisation, scope, host-integration, frames, dependencies, 1.0
13
+ ---
14
+
15
+ # ADR 053: An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None
16
+
17
+ ## Context
18
+
19
+ Janela dashboards are meant to be composed by agents, and an agent in a
20
+ host application has no way in. Frames and panes are rows (ADR 012), so
21
+ the only route is `Janela::Frame` and `Janela::Pane` written from a
22
+ console. A host that already runs an MCP server writes its own wrappers
23
+ by hand, and a host without one gets nothing. #73 asks for an MCP
24
+ surface, and ADR 010 named it as "one ADR away" and as one of the two
25
+ conditions for shipping Jan.
26
+
27
+ The issue proposed that Janela detect a host's MCP server and offer to
28
+ install tools into it. Four facts, measured on the demo before this was
29
+ written, change what that should mean.
30
+
31
+ **Reading a pane outside a controller reads everything.** The refusal in
32
+ ADR 032 lives in `Janela::ApplicationController`, where `policy_scope`
33
+ is asked. `Query#result` underneath takes an optional `on:` relation,
34
+ and without it reads the model's default scope. With two orders in the
35
+ demo, `pane.query.result` returned both statuses, and
36
+ `pane.query.result(on: Order.where(status: "paid"))` returned one. A tool
37
+ that called the first form would show an agent, and so a person, rows
38
+ the host never scoped, with no error. This is the same shape as ADR 034:
39
+ a thing that runs outside a request and has to be told what may be read.
40
+
41
+ **The host's scope is an answer given at the point of asking.** ADR 019
42
+ found that a decision needing the current user and tenant belongs inside
43
+ a request rather than in a setting, and ADR 032 rejected a global
44
+ `Janela.scope = ->(model, controller)` for it. An MCP call is not a
45
+ request to Janela's controllers. Who is asking arrives through whatever
46
+ authentication the host's own MCP server has, which Janela does not own
47
+ and cannot see.
48
+
49
+ **A pane row already refuses what it should.** A pane naming a measure or
50
+ model that is not declared is invalid, and says so in words an agent can
51
+ act on: `Measure "nonsense" is not a measure of Order` and `Model
52
+ "no_such_model" is not a janela model`. The tools do not need a
53
+ validation layer of their own.
54
+
55
+ **Tools can be built from data.** The official Ruby SDK, `mcp` 1.6.1,
56
+ builds a tool from a name, a description, a JSON schema and a block
57
+ (`MCP::Tool.define`), and hands the caller's context to the block as
58
+ `server_context`. Other libraries take the same four things. A tool
59
+ definition is therefore plain data plus a call, and none of it needs to
60
+ live in an MCP library to be written once.
61
+
62
+ One more fact corrects the issue. #73 describes the install task as "in
63
+ the same spirit as `rails janela:install:skill`". That task does not
64
+ exist. ADR 010 records the skill, the install task and the agent
65
+ definition as not implemented, and `docs/skills/` is absent. This would
66
+ be the first install task Janela has, not the second.
67
+
68
+ ### What was considered
69
+
70
+ **Janela serves MCP itself**, as an engine endpoint. Rejected. A served
71
+ endpoint needs authentication, a notion of who is calling and a way to
72
+ answer ADR 032's question for them, and Janela has none of the three: its
73
+ controllers are exactly as authenticated as the host's (ADR 010). Adding
74
+ them is the subsystem ADR 001 refused, and getting scope wrong shows one
75
+ tenant's numbers to another.
76
+
77
+ **Detect the host's MCP server and install into it**, as #73 proposed.
78
+ Rejected for the reason ADR 034 rejected detecting the kind of host: the
79
+ signal does not distinguish anything. A host's MCP setup is a gem, an
80
+ endpoint or a hand-written server, and the one thing Janela can reliably
81
+ know is none of those. It would also be Janela editing a host's tooling,
82
+ which ADR 010 says it never does.
83
+
84
+ **A `janela:install:mcp` task that writes tool files into the host.**
85
+ Rejected. Once copied the files are the host's, they drift from the
86
+ gem's public API, and the doctor would have to grow a check for each
87
+ release to say so. A host that wants the shape reads it in the guide and
88
+ owns the ten lines.
89
+
90
+ **A global scope setting**, so the tools need no argument. Rejected on
91
+ ADR 019 and ADR 032, above.
92
+
93
+ **`mcp` as a dependency of the gem.** Rejected. It is the right library
94
+ today and is not the only one a host may use, so a hard dependency would
95
+ make Janela's choice the host's. It stays out of the gemspec, the way the
96
+ demo's markdown renderer does, and the adapter below is optional.
97
+
98
+ **Writing and reading in one step.** Rejected as the shipped shape, not
99
+ as an idea. The write tools change records a person arranged, so they are
100
+ off until asked for.
101
+
102
+ ## Decision
103
+
104
+ **Janela ships its agent tools as plain Ruby that the host builds with a
105
+ scope and registers wherever it likes. Janela registers nothing,
106
+ detects nothing and serves nothing.**
107
+
108
+ ```ruby
109
+ tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }, write: true)
110
+
111
+ tools.all # name, description, input_schema, read_only
112
+ tools.call("add_pane", frame_id: 3, model: "orders", measure: "revenue")
113
+ ```
114
+
115
+ **`scope:` is required and has no default.** It is a callable that takes
116
+ a model class and returns a relation: the same answer `policy_scope`
117
+ gives. Leaving it out raises `Janela::Unscoped` when the tools are built,
118
+ not when one is called, which is ADR 032's refusal moved to the place
119
+ this surface begins. Every read the tools make passes the relation it
120
+ returns as `on:`, and frames and panes are read through the same callable
121
+ (`scope.call(Janela::Frame)`). It is an argument and not a setting,
122
+ because it is built where the host knows who is asking, as ADR 034's
123
+ `scope_for` is.
124
+
125
+ **Read tools first, and writes only when asked for.** The 1.0 set:
126
+
127
+ | Tool | Does |
128
+ | --- | --- |
129
+ | `describe_vocabulary` | The models, measures, dimensions, renderers and granularities `Janela.definitions` declares, so an agent offers only what can be asked |
130
+ | `list_frames` | Id, name, owner and pane count of the frames the scope returns |
131
+ | `get_frame` | One frame with its panes in position order |
132
+ | `read_pane` | A pane's values, through the host's scope, with `q[...]` filters bounded as ADR 025 bounds them |
133
+ | `add_pane`, `update_pane`, `remove_pane`, `move_pane` | Only with `write: true` |
134
+
135
+ Inputs are the pane's own attributes and the README's own names. There is
136
+ no agent only vocabulary (ADR 010). A rejected row returns the pane's
137
+ validation messages unchanged. A frame is found or made by the host with
138
+ `Frame.for` (ADR 041) or by an analyst on the engine's pages, so there is
139
+ no `create_frame` in 1.0.
140
+
141
+ **A tool definition is data, and an adapter is a few lines the host
142
+ owns.** `Janela::Tools#all` carries what any library needs. The guide
143
+ shows the mapping for the official `mcp` SDK, and a host using another
144
+ writes the same few lines. Janela adds no MCP library to its gemspec.
145
+
146
+ **Snapshots are not in 1.0.** A snapshot persists and publishes, so it
147
+ needs an owner (ADR 033) and a scope the caller has named (ADR 034). That
148
+ is two more decisions an agent should not be making by default, and it
149
+ waits for a host that wants it.
150
+
151
+ **Jan still does not ship.** ADR 010's second condition, that an MCP
152
+ surface exists, is met by this. Its first reason, that a skill loaded
153
+ agent already does the work, has not been tested, because the skill was
154
+ never built. The skill ships first and teaches these tools. An agent
155
+ definition is revisited after a host has used both, and ADR 010 is not
156
+ superseded here.
157
+
158
+ ## Consequences
159
+
160
+ - **It ships as 0.13.0, with its own short soak.** This adds public
161
+ surface, and ADR 037 says anything that changes the surface resets the
162
+ soak clock in #71. The earliest 1.0 tag moves by the length of that
163
+ soak. The rule for what 1.0 then contains is set now, before anyone has
164
+ seen the result: if a real host has exercised the write tools and found
165
+ nothing wrong, 1.0 ships with them. If no host has used them by the end
166
+ of the soak, 1.0 ships read only and the write tools wait for 1.1.
167
+ Freezing four schemas that nobody has tried is what a soak is for
168
+ avoiding.
169
+ - **The tool names and input schemas join the contract at 1.0**, so the
170
+ set is small on purpose and every name is a verb and a README noun. A
171
+ tool added after 1.0 is a minor release. A tool changed is a major one.
172
+ - **A host does the registering.** That is a few lines in the place its
173
+ MCP server already lives, and the guide says where. A host with no MCP
174
+ server gets nothing from this, which is the honest answer: Janela does
175
+ not run servers.
176
+ - **The first test is the measured failure.** A tool built with a scope
177
+ that returns `Order.none` reads no values, and tools built without a
178
+ scope raise. Both belong in the suite before the code.
179
+ - **No doctor check.** Construction raises, so there is nothing for the
180
+ doctor to see that the raise does not say (ADR 035).
181
+ - **The pane `order` option (#75) reaches agents for free**, since the
182
+ write tools take a pane's attributes. It does not change this ADR.
183
+ - **What would change this decision:** two hosts writing the same
184
+ adapter by hand, which would earn a shipped one for that library; or a
185
+ host with no MCP server that needs one, which would be the day Janela
186
+ is asked to run something and has to answer ADR 032's question for
187
+ itself.