janela 0.11.0 → 0.12.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +26 -6
  4. data/UPGRADING.md +19 -0
  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/controllers/janela/panes_controller.rb +2 -2
  9. data/app/controllers/janela/queries_controller.rb +3 -0
  10. data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
  11. data/app/helpers/janela/frames_helper.rb +12 -4
  12. data/app/models/janela/pane.rb +32 -1
  13. data/app/models/janela/query.rb +116 -4
  14. data/app/views/janela/panes/_form.html.erb +25 -0
  15. data/app/views/janela/queries/_query.html.erb +24 -2
  16. data/config/locales/en.yml +19 -0
  17. data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
  18. data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
  19. data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
  20. data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
  21. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
  22. data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
  23. data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
  24. data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
  25. data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
  26. data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
  27. data/docs/decisions/052-how-the-project-is-run.md +93 -0
  28. data/docs/decisions/INDEX.md +24 -18
  29. data/docs/roadmap.md +12 -27
  30. data/docs/theming.md +4 -0
  31. data/lib/janela/definition.rb +71 -4
  32. data/lib/janela/dimension.rb +7 -0
  33. data/lib/janela/engine.rb +13 -1
  34. data/lib/janela/measure.rb +56 -7
  35. data/lib/janela/version.rb +1 -1
  36. metadata +17 -10
@@ -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,6 +13,9 @@
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
+ <%# 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 = capture do %>
16
19
  <canvas class="janela-chart"
17
20
  data-controller="janela--chart"
18
21
  data-action="janela--chart:toggle->janela--frame#toggle"
@@ -24,11 +27,27 @@
24
27
  data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
25
28
  data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
26
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? %>
27
31
  role="img" aria-labelledby="<%= title_id %>"></canvas>
32
+ <% end %>
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
@@ -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
@@ -0,0 +1,169 @@
1
+ ---
2
+ Date: 2026-09-30
3
+ Status: Accepted
4
+ Related: ADR 005, ADR 012, ADR 016, ADR 029, ADR 036, ADR 037, ADR 042, ADR 046
5
+ Triggers:
6
+ - giving a chart pane a height, an aspect ratio or any size of its own
7
+ - adding a parameter to the pane URL, or a column to janela_panes
8
+ - adding a class to the scale in janela.css, or a wrapper inside a chart pane
9
+ - a chart that is too tall, too short or squashed in a stored frame
10
+ - deciding what a renderer does with a setting that is not meant for it
11
+ Topics: charts, css, layout, styling, public-api, panes, frames, urls
12
+ ---
13
+
14
+ # ADR 047: A Chart's Height Is One of Five Steps, and Unset Changes Nothing
15
+
16
+ ## Context
17
+
18
+ #24: a chart draws with Chart.js's defaults, `responsive` and
19
+ `maintainAspectRatio: true` at 2:1, and a host cannot correct it from
20
+ outside. A second report widened it: a stored frame has no height
21
+ vocabulary at all. Its layout is width only, `columns`, `gap` and `span`
22
+ (ADR 012), so an analyst with a three-row frame of charts could make it
23
+ shorter only by swapping the charts for tables, which changes what the
24
+ dashboard says to change how tall it is.
25
+
26
+ Measured in a browser against the demo before deciding, and two things the
27
+ issue assumed turned out to be wrong.
28
+
29
+ **A chart is not enormous today.** A full width line in a 1020px column
30
+ renders 1020 by 320 and a bar in one column of three renders 301 by 150.
31
+ `canvas.janela-chart { max-height: 20rem }` already caps it at 320px, and
32
+ the canvas bitmap and its CSS size agree.
33
+
34
+ **A host can lower a chart from outside and cannot set one.** A host's
35
+ `max-height: 120px !important` took the line to 1020 by 120, redrawn
36
+ rather than squashed, so it does not "fight the resize loop". But
37
+ `height: 240px !important` on the bar is ignored (still 150, because the
38
+ height is derived from the width), and `min-height: 240px` stretches the
39
+ bitmap: the box is 240 and the chart inside it is 150, so it is drawn
40
+ distorted. Exactly N tall, or taller than 2:1, cannot be said from outside.
41
+
42
+ **What works is a box of fixed height around the canvas.** Built fresh with
43
+ `maintainAspectRatio: false` inside a `position: relative` box, a chart
44
+ takes the box's height exactly, redraws when the box is resized, and does
45
+ the same in a 300px column. (Patching the option onto a chart that was
46
+ built with the aspect ratio on gave unreliable readings, so a height has
47
+ to be known when the chart is made.) Five heights derived from the spacing
48
+ unit, `--janela-space` at its default of 0.25rem, measured at 96, 160, 224,
49
+ 320 and 448 pixels and each came out exactly. The fifth is squashed to 320
50
+ by the existing `max-height: 20rem` unless that rule is lifted inside the
51
+ box.
52
+
53
+ ### What was considered
54
+
55
+ **Pixels, `height: 240`, as the issue proposed.** Works for the helper and
56
+ not for a row. ADR 016 keeps an analyst's number out of CSS: a stored
57
+ integer selects a class that is already written, and a pane row taking
58
+ pixels would be the first place an analyst's number reaches a style. A
59
+ scale is the vocabulary `span`, `columns` and `gap` already speak.
60
+
61
+ **`aspect:` instead of, or beside, `height`.** An aspect ratio means the
62
+ height still follows the width, so it does not help a stored frame whose
63
+ panes sit in different columns, and it would be a second way to say what a
64
+ height says. Not needed, and the issue itself said one of the two was
65
+ probably enough.
66
+
67
+ **A frame-level maximum.** It sizes every pane at once but is a second way
68
+ to say what per pane heights already say, and it cannot make one chart
69
+ shorter than its neighbours. Not needed once panes have a height.
70
+
71
+ **`rows`, a grid row span.** A different thing: a tall pane beside two
72
+ short ones. It is worth having and is kept apart, since folding it into a
73
+ chart height would make one field mean two things.
74
+
75
+ **Lift the 20rem cap for everyone.** Changes every existing chart. Unset
76
+ must change nothing.
77
+
78
+ ## Decision
79
+
80
+ **A pane may carry a `height` of 1 to 5, drawn as a box of that fixed
81
+ height around a bar or line chart. Unset is exactly what it is today.**
82
+
83
+ | height | box | default size |
84
+ | --- | --- | --- |
85
+ | 1 | `calc(var(--janela-space) * 24)` | 96px |
86
+ | 2 | `calc(var(--janela-space) * 40)` | 160px |
87
+ | 3 | `calc(var(--janela-space) * 56)` | 224px |
88
+ | 4 | `calc(var(--janela-space) * 80)` | 320px |
89
+ | 5 | `calc(var(--janela-space) * 112)` | 448px |
90
+
91
+ Multiples of the base unit, so a theme that sets `--janela-space` moves the
92
+ whole scale with the rest (ADR 016), and step 4 is today's cap exactly, so
93
+ "tall" means what a chart already is at full width. The class names follow
94
+ ADR 016's `janela-{property}-{scale}`: `janela-h-1` to `janela-h-5`, on a
95
+ `div.janela-chart-box` that wraps the canvas inside the figure, between the
96
+ `figcaption` and the canvas, so ADR 042's structure otherwise stands.
97
+ A host that wants an exact pixel value sets it against the class in its own
98
+ CSS. Inside the box the canvas takes the box's height and the 20rem cap
99
+ does not apply to it; the chart controller builds the chart with
100
+ `maintainAspectRatio: false` when the pane carries a height.
101
+
102
+ **With no height there is no box.** The markup, the classes, the cap and
103
+ the 2:1 rule are unchanged, so nothing that renders today renders
104
+ differently and `//figure[...]/canvas` still finds a canvas.
105
+
106
+ **One vocabulary in three places.**
107
+
108
+ ```erb
109
+ <%= janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2 %>
110
+ ```
111
+
112
+ - `Janela::Pane#height`, an integer column that is null when unset,
113
+ validated to `1..5` the way `span` is to `1..12`, and offered as a
114
+ select on the pane form (blank is "Automatic").
115
+ - `janela_pane` and `janela_snapshot_pane` take `height:`.
116
+ - The pane URL carries it as `?height=2`, because the pane's content is
117
+ rendered by the server from that URL, lazily and again on every
118
+ cross-filter, and a height left out of the URL would be lost on the first
119
+ refresh. A stored pane already refreshes from its row, which supplies it.
120
+
121
+ **Height is not part of the pane's identity.** ADR 029 identifies a frame
122
+ by who it is and not what it shows, and a height is a matter of drawing, not
123
+ of which query this is. It stays out of `Query.turbo_frame_id`, which is
124
+ also what lets a host change a pane's height in place without Turbo
125
+ reconciling into a different frame. Two helper panes over an identical query
126
+ that differ only in height still need an `id:`, exactly as two identical
127
+ panes do now.
128
+
129
+ **A height on a renderer that does not draw a canvas is ignored, not an
130
+ error.** A ring is SVG that scales to its width to a maximum of 16rem
131
+ (ADR 046), a table and a single value have no chart, and a snapshot pane
132
+ draws as its own renderer says. A pane whose renderer is switched from bar
133
+ to doughnut and back keeps the height it had, and a row is not made invalid
134
+ by a setting that is meaningless for what it now shows. What a ring's size
135
+ should be is left open.
136
+
137
+ ## Consequences
138
+
139
+ - An analyst can shorten or lengthen a chart in a stored frame without
140
+ changing what it says, and a host can do the same in the helper, from one
141
+ vocabulary. Nothing about pixels reaches CSS.
142
+ - **A migration for stored frames.** `janela_panes` gains a nullable
143
+ `height`, additive, so every existing pane is unset and unchanged. A
144
+ host that uses stored frames runs `bin/rails janela:install:migrations`
145
+ and `db:migrate`, in `UPGRADING.md` and the post install message
146
+ (ADR 015). A host that does not use stored frames has nothing to do.
147
+ - **The pane URL grammar grows by one optional query parameter,** which
148
+ ADR 037 counts as public surface, so it belongs before 1.0 and not after.
149
+ `docs/theming.md` gains `janela-h-1` to `janela-h-5` and
150
+ `janela-chart-box` (ADR 036), and the README documents `height:`.
151
+ - The chart controller reads one new value, which says whether a height was
152
+ set. A height changes when the chart is *built*, so `repoint` to a URL
153
+ with a different height replaces the pane content and the chart is made
154
+ again, which Turbo already does on every replacement.
155
+ - A pane can now look too small to read: step 1 is 96px, tall enough for
156
+ a sparkline and not for a chart with a legend and axis labels. That is the
157
+ analyst's choice and the scale's lowest rung is deliberately that small.
158
+ - Vitral, the optional theme, does not touch heights: it has no rule for
159
+ chart sizing and does not set `--janela-space`, so the scale reads the
160
+ same under it. Sizing is structure and belongs to `janela.css` (ADR 023),
161
+ and because the classes join the published contract a theme may override
162
+ them; none has to.
163
+ - Not covered: rings, `rows`, a frame level maximum, and the gallery's
164
+ configuration form, which offers renderer, granularity and limit and can
165
+ offer height in a later change without a decision.
166
+ - What would change this decision: a real need for a height between two
167
+ steps, which is an argument for a sixth step or for a host's own class
168
+ and not for pixels; or a need for the height to follow a container rather
169
+ than be fixed, which is a different feature.
@@ -0,0 +1,154 @@
1
+ ---
2
+ Date: 2026-09-30
3
+ Status: Proposed
4
+ Related: ADR 003, ADR 009, ADR 025, ADR 029, ADR 030, ADR 032, ADR 034, ADR 037, ADR 039
5
+ Triggers:
6
+ - a dashboard that should update on a timer, on a push, or when a record changes
7
+ - a host reaching for Turbo Streams, ActionCable or a broadcast to update a pane
8
+ - adding an action or an event to the frame controller
9
+ - a request for a refresh interval, a polling option or a stream_from helper
10
+ - reloading a pane's turbo frame from a host's own JavaScript
11
+ Topics: cross-filtering, frames, javascript, hotwire, performance, authorisation, public-api
12
+ ---
13
+
14
+ # ADR 048: A Frame Can Be Told to Refresh, and Janela Never Decides When
15
+
16
+ ## Context
17
+
18
+ The question: should a frame be able to refresh itself every few seconds,
19
+ or stream from something, so a dashboard on a wall stays current? Janela
20
+ has nothing for it. ADR 003 chose Turbo Frames for cross-filtering on the
21
+ ground that a dashboard needs no streams, sockets or client state, and the
22
+ roadmap lists a refresh-scheduling interface among the things that are not
23
+ coming, on the reasoning that a host already has a scheduler and snapshots
24
+ are an ActiveJob. That reasoning was about *snapshots*. Refreshing what a
25
+ reader is looking at, in their browser, is a different request and is not
26
+ answered by it.
27
+
28
+ Three things were established before deciding.
29
+
30
+ **Pushing rendered content cannot work, and would leak if it did.** A
31
+ pane's numbers depend on who is asking. `QueriesController#show` renders
32
+ through `janela_scope`, which calls the *request's own controller* for
33
+ `policy_scope` (ADR 032), and the reader's filters live in their browser as
34
+ `q[...]` (ADR 008). A Turbo Stream broadcast renders once, outside any
35
+ request, with no reader whose scope or filters exist, and sends the same
36
+ HTML to every subscriber. Where it rendered at all it would show one
37
+ person's scope to everyone else, which is the failure ADR 032 and ADR 034
38
+ exist to prevent. So the server can never usefully push a pane's content.
39
+
40
+ **What can be pushed is a nudge, and the refetch belongs to each browser.**
41
+ "Something changed" carries no data. Each browser then re-fetches its own
42
+ panes with its own filters, through the same scoped request it always made,
43
+ so a viewer only ever gets numbers they were entitled to.
44
+
45
+ **A host can already do the refetch, and it already behaves.** Measured in
46
+ a browser against the demo, with all nine panes of the orders dashboard
47
+ loaded and one value selected:
48
+
49
+ - Calling `reload()` on every pane, as a timer would, refetched each at the
50
+ address Janela last asked for, with the filters. None of it was
51
+ unfiltered.
52
+ - With a click and a timer tick landing in the same instant, so that every
53
+ pane was reloaded while the click's own refetch was in flight, all seven
54
+ text panes ended on the filtered numbers, matching the server's answer for
55
+ each. The supersede rule in the frame controller (#33) already drops a
56
+ request for an address that is no longer what was asked, so a tick cannot
57
+ put stale numbers back. One run, not a proof, but the mechanism is the one
58
+ #33 built for exactly this.
59
+ - Before the panes had scrolled into view, a reload of all nine made seven
60
+ requests, consistent with a lazy pane that has not loaded not being
61
+ fetched by a reload.
62
+
63
+ So the mechanism is not missing. What is missing is a name for it that a
64
+ host can rely on. ADR 030 says a host talks to the frame and does not write
65
+ a pane's `src`, and `reload()` on each frame is the host reaching past the
66
+ frame controller into Turbo, with no promise about what it does for a
67
+ pane that holds words and has no `src` (ADR 039), for a pane whose request
68
+ is already in flight, or for a snapshot pane that is never live (ADR 009).
69
+
70
+ ### What was considered
71
+
72
+ **A `refresh_every:` option on a frame or a pane.** Rejected. Every tick is
73
+ one query per pane per viewer, so an interval is a decision about load, and
74
+ ADR 025 bounds what one query may ask for and says nothing about how often
75
+ a page asks. Somebody has to own that number, and it is the host, who knows
76
+ its database, its audience and whether the wall display is one browser or
77
+ two hundred. A setting would also put a polling interval where an analyst
78
+ can edit it, which ADR 016 keeps numbers out of. If the numbers are
79
+ expensive, snapshots are the answer, not a faster poll.
80
+
81
+ **A `stream_from` helper, or broadcasting the rendered pane.** Rejected for
82
+ the scope reason above. A helper that only sent the nudge would be a thin
83
+ wrapper over `turbo_stream_from` and a custom action, which the host writes
84
+ in a line, and Janela would then own a transport it has no view of.
85
+
86
+ **Doing nothing, and telling hosts to call `reload()`.** Rejected, because
87
+ the unsupported parts above are exactly where it goes wrong quietly: a
88
+ content pane, a snapshot pane, a lazy pane forced to load early, or a pane
89
+ restarted for no reason while it is already fetching the newest thing.
90
+
91
+ ## Decision
92
+
93
+ **The frame controller gains one action, `refresh`, which re-fetches the
94
+ panes that are live and loaded at what was last asked, and Janela provides
95
+ no timer, interval, transport or stream.**
96
+
97
+ ```html
98
+ <button data-action="janela--frame#refresh">Refresh</button>
99
+ ```
100
+
101
+ ```js
102
+ // a timer the host owns, paused while the tab is hidden
103
+ setInterval(() => { if (!document.hidden) frame.dispatchEvent(new CustomEvent("janela--frame:refresh")) }, 3000)
104
+
105
+ // or a nudge from ActionCable, a Turbo Stream custom action, anything
106
+ ```
107
+
108
+ It is also an event the frame listens for, `janela--frame:refresh`, wired
109
+ the way `repoint` is (ADR 030), so code outside the frame can ask without
110
+ holding a reference to the controller.
111
+
112
+ **What refresh does.** For each pane the frame controller manages, it
113
+ re-fetches the address last asked for, with the reader's filters and any
114
+ host-fixed `where` (ADR 040), through the same supersede rules a click uses.
115
+ It leaves alone:
116
+
117
+ - a pane with no live address: a pane that holds words or a host partial
118
+ (ADR 039), and a snapshot pane (ADR 009);
119
+ - a pane that has not loaded yet, so a lazy pane still loads when it scrolls
120
+ into view and a refresh does not pull the whole page forward;
121
+ - a pane whose request for the current address is already in flight, since
122
+ it is about to show the newest thing.
123
+
124
+ **It is a nudge in the other direction too.** Refresh reads no data and
125
+ sends none, so it cannot show anyone anything they could not already see:
126
+ every fetch it causes is the ordinary scoped request (ADR 032).
127
+
128
+ **Where the timer and the transport live.** In the host's code, documented
129
+ as recipes with the cost stated: a tick is one query per pane per viewer,
130
+ so pause it while the tab is hidden and choose an interval the database can
131
+ carry. The README gains a short section, and `docs/composing.md` may carry
132
+ the longer recipes.
133
+
134
+ ## Consequences
135
+
136
+ - A wall dashboard is a `setInterval` and one line, a "refresh" button is
137
+ one attribute, and a push from a background job is an ActionCable channel
138
+ and one line of JavaScript. Janela owns none of the three, and none of
139
+ them can leak across readers.
140
+ - **Additive, so it does not need to land before 1.0.** A new action and a
141
+ new event are additions to the frame controller's surface, which ADR 037
142
+ freezes for changes and not for additions. That is why this is filed for
143
+ after 1.0. The name is worth choosing well (`refresh` and
144
+ `janela--frame:refresh` sit beside `clear` and `repoint`).
145
+ - The supersede rule (#33) becomes load bearing for something new: a tick
146
+ arriving during a click's fetch. Build must test that deliberately, by
147
+ holding a response in flight, rather than rely on the single run above.
148
+ - A host that polls carries the load of it. This ADR states that; it does
149
+ not bound it.
150
+ - Nothing changes for a host that does not use it.
151
+ - What would change this decision: a measured case where a host cannot
152
+ reasonably own the timer, which would be an argument for a helper that
153
+ only wires a host-supplied interval, never one that chooses it; or a
154
+ way to broadcast that renders per reader, which Turbo does not offer.
@@ -0,0 +1,143 @@
1
+ ---
2
+ Date: 2026-09-30
3
+ Status: Accepted
4
+ Related: ADR 008, ADR 024, ADR 025, ADR 028, ADR 037, ADR 040, ADR 043
5
+ Triggers:
6
+ - selecting the (none) group together with a value
7
+ - a filter that carries both _in and _null for one dimension
8
+ - changing what a pair of filter parameters means, or how they combine
9
+ - the frame controller's handling of the null group in toggle
10
+ - a host's where: or default_where naming a value and the null group
11
+ Topics: cross-filtering, ransack, urls, dsl, accessibility, roadmap
12
+ ---
13
+
14
+ # ADR 049: The Null Group Is One More Value in a Selection
15
+
16
+ ## Context
17
+
18
+ #69: with a chart of A, B and (none) you can select A and B together but not
19
+ A and (none). This is ADR 024 working as written. It made the null group
20
+ exclusive within its dimension, because a click writes `channel_in[]=web` for
21
+ a value and `channel_null=1` for the group, Ransack ANDs its conditions, and
22
+ the pair asks for rows whose channel is both null and web. That returns zero
23
+ rows without saying so, and the ADR chose to refuse the combination over
24
+ rendering an empty dashboard. What it could not offer was a way to say "web
25
+ or none", and a reader now wants exactly that.
26
+
27
+ Measured against the demo (channel is nil on one row, `phone` on one and
28
+ `web` on two), through Janela's own filter path:
29
+
30
+ | Filter | Rows |
31
+ | --- | --- |
32
+ | `channel_in: [web]` and `channel_null: 1` | 0 |
33
+ | `channel_in: [web, nil]` | 2 (Ransack drops the nil: no null rows) |
34
+ | `channel_in: [web, ""]` | 2 (same) |
35
+ | `channel_eq_any: [web, nil]` | refused, not an allowed predicate (ADR 025) |
36
+ | `g[]` with `m: or` | refused, and ADR 024 rejected it for an unreadable URL |
37
+ | `channel_not_in: [phone]` | 2 (SQL drops the null rows) |
38
+
39
+ So nothing Ransack offers through Janela's allowed predicates says "A or none".
40
+
41
+ **But Ransack can say it if Janela asks for it.** Given Ransack's own
42
+ grouping, built by Janela and never appearing in a URL:
43
+
44
+ - `g: [{ m: "or", channel_in: ["web"], channel_null: "1" }]` returns 3, as
45
+ `WHERE channel IN ('web') OR channel IS NULL`.
46
+ - Merged with another filter it combines as AND:
47
+ `status IN ('paid') AND (channel IN ('web') OR channel IS NULL)`, 2 rows.
48
+ - Through an association it keeps the join:
49
+ `LEFT OUTER JOIN customers ... WHERE (customers.region IN ('APAC') OR
50
+ customers.region IS NULL)`.
51
+
52
+ So the union is expressible without a new predicate, a new grammar, or
53
+ anything in the URL a person has to learn.
54
+
55
+ ### What was considered
56
+
57
+ **A new predicate, `in_or_null`.** `q[channel_in_or_null][]=web`. Rejected.
58
+ It is a second way to spell a selection that Janela already spells with two
59
+ keys it already reads, it would be a new entry in ADR 025's list for a
60
+ question that list already answers, and the frame controller would have to
61
+ translate between the two forms when a value is added to a selection that
62
+ held the null group.
63
+
64
+ **The null inside the `_in` list, as a token such as `(none)`.** Rejected.
65
+ It collides with a real value that is spelled `(none)`, and Ransack drops the
66
+ nil it would have to become.
67
+
68
+ **`g[]` grouping in the URL.** Still rejected, for ADR 024's reason: the URL
69
+ is one a person reads and edits (ADR 008). Using the grouping *inside* Janela
70
+ is a different thing; nothing in the URL changes.
71
+
72
+ **Leave it exclusive.** The present state. It costs a question a dashboard
73
+ plainly has ("web or unknown") that can be answered by no gesture.
74
+
75
+ ## Decision
76
+
77
+ **The null group is one more member of a selection. A dimension that carries
78
+ `_in` (or `_eq`) and `_null` means the union, and the frame controller adds
79
+ and removes the null group like any other value.**
80
+
81
+ **At the server.** `Definition#filter`, the one choke point every filter
82
+ passes through (ADR 025), validates the parameters exactly as it does now:
83
+ declared dimensions, the allowed predicates, the value ceiling. It then builds
84
+ the result with each such pair for one dimension taken out of the AND and
85
+ applied as an OR of the two, merged back with everything else. The URL is
86
+ what it was:
87
+
88
+ ```
89
+ q[channel_in][]=web&q[channel_null]=1
90
+ ```
91
+
92
+ This applies to a reader's `q[...]`, a host's `where:` (ADR 040) and a
93
+ frame's `default_where` (ADR 043) alike, because they share the choke point.
94
+ Today that pair returns zero rows, so no working link or stored filter depends
95
+ on it.
96
+
97
+ **It is the inclusions that union, and the exclusions stay an intersection.**
98
+ "A or none" is two ways of being selected, so they OR. `not_in` with
99
+ `not_null` is two ways of being ruled out ("anything but A, and not none"),
100
+ so they AND, as they do now. `_null` with `_not_null` remains a contradiction
101
+ and returns nothing.
102
+
103
+ **At the frame controller.** `toggle` stops treating the null key as
104
+ exclusive. The gestures are ADR 024's, unchanged:
105
+
106
+ | Gesture | Result |
107
+ | --- | --- |
108
+ | Click (none) | (none) alone is selected |
109
+ | Click a value while (none) is selected | that value alone replaces it |
110
+ | Ctrl or Cmd click (none) | (none) is added to, or removed from, the selection |
111
+ | Ctrl or Cmd click a value while (none) is selected | the value is added, (none) stays |
112
+
113
+ `clearDimension` still clears the dimension's keys when a plain click replaces
114
+ the selection. The highlight needs nothing: a pane already marks `(none)`
115
+ among the selected values (`Query#selected_values`).
116
+
117
+ ## Consequences
118
+
119
+ - A dashboard can answer "A or none", which nothing could before, and a
120
+ selection of A, B and (none) is three ordinary members of one set.
121
+ - **ADR 024's exclusivity paragraph is superseded.** Its measurement of the
122
+ AND stands and is why the union is built explicitly. The rest of 024, the
123
+ gestures, `_in`, the keyboard, stands. ADR 024's status line says so.
124
+ - **The meaning of a documented pair of parameters changes**, which ADR 037
125
+ counts as public surface, and is why this belongs before 1.0. It is
126
+ narrowly breaking: the pair returned no rows before, so the only thing that
127
+ could depend on it is a link or a stored filter that always showed an empty
128
+ pane. `CHANGELOG.md` says so under Changed, and no `UPGRADING.md` entry is
129
+ needed because there is nothing to change.
130
+ - The chart and the table need no change of their own: the null group is
131
+ already a label with a filter, and the selection highlight already includes
132
+ it.
133
+ - A snapshot taken under the pair stores its filters as it does any others
134
+ (ADR 009), and a snapshot taken before this holds the zero rows it always
135
+ did. Stored results are never re-run.
136
+ - Build must test the choke point for `_in` and `_eq` each with `_null`,
137
+ through an association, alongside a filter on another dimension, with the
138
+ value ceiling still enforced, with `not_in` and `not_null` still
139
+ intersecting, and the frame controller in a browser for the four gestures
140
+ above.
141
+ - What would change this decision: a host needing the union for a *range* on
142
+ a time dimension, which is an OR of two ranges and a different feature
143
+ (ADR 045), or a dimension where "none" is not a value at all but an error.