janela 0.10.0 → 0.11.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.
@@ -0,0 +1,41 @@
1
+ <%# A doughnut or a pie, drawn here rather than on a canvas so it is in the
2
+ HTML before any JavaScript runs, prints, and can be read and operated by
3
+ a keyboard through the legend (ADR 046, ADR 026). A slice and its legend
4
+ row toggle the same filter, and the legend is the control a keyboard and a
5
+ screen reader use, so the slices are not offered to them twice. %>
6
+ <% title_id = "#{query.turbo_frame_id}-title" %>
7
+ <% slices = query.slices(result) %>
8
+ <% selecting = slices.any?(&:selected) %>
9
+ <figure class="janela-pane janela-ring">
10
+ <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
11
+ <svg class="janela-ring-svg" viewBox="0 0 100 100" role="img" aria-labelledby="<%= title_id %>"
12
+ data-hole="<%= query.hole? %>">
13
+ <% slices.each do |slice| %>
14
+ <% path = slice.path(hole: query.hole?) %>
15
+ <% next unless path %>
16
+ <%= tag.path d: path, class: [ "janela-ring-slice", ("janela-dim" if selecting && !slice.selected) ],
17
+ style: "fill: var(#{slice.property})",
18
+ data: (slice.click ? { action: "click->janela--frame#toggle" }.merge(slice.click) : {}) do %>
19
+ <title><%= slice.label %>: <%= slice.formatted %></title>
20
+ <% end %>
21
+ <% end %>
22
+ </svg>
23
+ <table class="janela-legend">
24
+ <tbody>
25
+ <% slices.each do |slice| %>
26
+ <tr>
27
+ <td>
28
+ <span class="janela-swatch" style="background: var(<%= slice.property %>)" aria-hidden="true"></span>
29
+ <% if slice.click %>
30
+ <%= tag.button slice.label, type: "button", aria: { pressed: slice.selected },
31
+ data: { action: "janela--frame#toggle" }.merge(slice.click) %>
32
+ <% else %>
33
+ <span><%= slice.label %></span>
34
+ <% end %>
35
+ </td>
36
+ <td><%= slice.formatted %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </figure>
@@ -66,5 +66,11 @@ en:
66
66
  title_placeholder: Janela writes one if you leave this blank
67
67
  renderers:
68
68
  bar: Bar chart
69
+ doughnut: Doughnut chart
69
70
  line: Line chart
71
+ pie: Pie chart
70
72
  table: Table
73
+ time:
74
+ click_hint: Choose a period to filter the other panes to it. Ctrl or Cmd does not add a second period.
75
+ rings:
76
+ not_drawable: A ring cannot show a negative value or a total of nothing, so this is drawn as a table.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  Date: 2026-09-15
3
- Status: Accepted
3
+ Status: Accepted (the click source paragraph is superseded by ADR 045)
4
4
  Related: ADR 002, ADR 005
5
5
  Triggers:
6
6
  - grouping a measure by a date or time column
@@ -0,0 +1,140 @@
1
+ ---
2
+ Date: 2026-09-29
3
+ Status: Accepted
4
+ Related: ADR 024, ADR 025, ADR 028, ADR 040, ADR 043
5
+ Triggers:
6
+ - adding not_eq or not_in to a dimension's allowed predicates
7
+ - a frame's default filter, a host's fixed filter or a reader's q[...]
8
+ needing to say "not this value" rather than listing every other one
9
+ - widening CATEGORICAL_PREDICATES or TIME_PREDICATES
10
+ - reasoning about which dashboard question justifies a new predicate
11
+ Topics: security, ransack, dsl, frames, cross-filtering
12
+ ---
13
+
14
+ # ADR 044: A Categorical Dimension Can Say What It Excludes, Not Only What It Includes
15
+
16
+ ## Context
17
+
18
+ #64: a categorical dimension's allowed predicates (ADR 025, corrected by
19
+ ADR 028) are `eq`, `in`, `null` and `not_null`. There is no way to say
20
+ "not this one." Reproduced against the demo:
21
+
22
+ ```ruby
23
+ Order.janela.narrow(Order.all, "status_not_eq" => "refunded")
24
+ # => Janela::BadRequest: Order does not allow status_not_eq. This
25
+ # dimension allows status_eq, status_in, status_null, status_not_null.
26
+ ```
27
+
28
+ `Definition#narrow`/`#filter` is the single choke point every filter
29
+ source goes through (ADR 025), so a reader's `q[...]`, a host's `where:`
30
+ (ADR 040) and a frame's `default_where` (ADR 043) all hit the same wall.
31
+
32
+ **The question is not whether the list is wrong, the way ADR 028 found
33
+ it.** ADR 028 restored `not_null` because it was already shipped, tested
34
+ behaviour that a first-principles list had missed. `not_eq`/`not_in` are
35
+ not already shipped anywhere; nothing measured today shows them working.
36
+ This is a request for new capability, and ADR 028 already set the bar for
37
+ that: *"a predicate belongs there if a dashboard produces it, or if it is
38
+ a boolean test of presence rather than a way to phrase a match."*
39
+ `not_eq`/`not_in` fail both readings as ADR 028 wrote them: ADR 024 says
40
+ a click only ever writes `eq`/`in`, and excluding a value is not a
41
+ presence test the way `not_null` is.
42
+
43
+ **But a dashboard question that needs it already exists, and it is the
44
+ one that motivated #63.** ADR 043 built a frame's permanent
45
+ `default_where` for exactly this sentence: "this queue never counts an
46
+ archived row." That sentence can be typed today only as `status_in` with
47
+ every value except `archived`, and #64's own point stands: that
48
+ inclusion list silently stops covering the dashboard the day a new
49
+ status value is added, which is the same quiet wrongness ADR 024, ADR
50
+ 032 and ADR 034 all already refuse elsewhere. An exclusion degrades
51
+ differently from an inclusion when the data shape changes under it, and
52
+ "never" is a more honest word for what `default_where` is for than a
53
+ list that has to be kept in sync with every value a column might hold.
54
+
55
+ ### What was considered
56
+
57
+ **Leave it, and require `_in` naming every value but the excluded one.**
58
+ Rejected for the reason above: it is not equivalent, it is a
59
+ maintenance trap that looks correct until a new value appears.
60
+
61
+ **Allow `not_eq`/`not_in` only through `where:`/`default_where`, not
62
+ through the reader's `q[...]`.** Considered, because the motivating case
63
+ is a frame's own permanent filter, not something a reader asks for.
64
+ Rejected: `narrow` is deliberately the one choke point every caller
65
+ shares (ADR 025), and splitting it into a predicate list parameterised
66
+ by which caller is asking is new configurability of exactly the kind
67
+ ADR 021 and ADR 023's test rejects, to protect nothing. ADR 040 already
68
+ says a fixed filter is a view filter, not an authorisation boundary: a
69
+ reader who can edit a pane URL can already ask for anything the
70
+ dimension allows, so refusing the reader `not_eq` while allowing a host
71
+ the same predicate on the same attribute buys no security, only a
72
+ second thing to explain.
73
+
74
+ **A fourth verb in the DSL, "exclude," alongside ADR 025's "select" and
75
+ "range" groupings.** Rejected. Nothing about `not_eq`/`not_in` needs a
76
+ name Ransack does not already give it; the project keeps the DSL a thin
77
+ layer over Ransack rather than a query language of its own (CLAUDE.md's
78
+ forkability vision), and "select"/"range" were never a formal surface,
79
+ only informal shorthand for reading ADR 025. Two more entries in the
80
+ existing list cost nothing that a new grouping would justify.
81
+
82
+ **Time dimensions get their own decision about whether they need it.**
83
+ Considered, and rejected as unnecessary work: no dashboard question
84
+ measured here or in #63 asks to exclude a single instant from a time
85
+ range, so nothing argues for building it. But `TIME_PREDICATES` is
86
+ `CATEGORICAL_PREDICATES + %w[gteq gt lteq lt]` (`lib/janela/dimension.rb`),
87
+ the same way it already inherits `not_null` without a time-specific
88
+ argument for that either. Carving `not_eq`/`not_in` out of the inherited
89
+ half would be a special case with nothing behind it. It stays inherited.
90
+
91
+ ## Decision
92
+
93
+ **A categorical dimension's allowed predicates gain `not_eq` and
94
+ `not_in`, for the same reason `not_null` is already there: a real
95
+ dashboard question, already built on top of this list (ADR 043),
96
+ cannot be phrased any other way that survives a new value being added.**
97
+
98
+ ```ruby
99
+ # lib/janela/dimension.rb
100
+ CATEGORICAL_PREDICATES = %w[eq in not_eq not_in null not_null].freeze
101
+ ```
102
+
103
+ `TIME_PREDICATES` inherits both, unchanged in its own definition. No new
104
+ DSL vocabulary is introduced: `not_eq` and `not_in` are exactly Ransack's
105
+ own predicate names, entries in the same flat list every other allowed
106
+ predicate already sits in.
107
+
108
+ This predicate is reachable through `where:` (ADR 040), `default_where`
109
+ (ADR 043) and a hand-built or shared `q[...]` link, the same as every
110
+ other allowed predicate. It is not reachable through a click: ADR 024
111
+ writes only `eq`/`in`, and toggling a value never produces an exclusion.
112
+ Worth saying plainly wherever this is documented, so nobody goes looking
113
+ in the frame UI for a gesture that was never built.
114
+
115
+ ## Consequences
116
+
117
+ - #63's motivating sentence, "this queue never counts an archived row,"
118
+ becomes expressible as `default_where: { "status_not_eq" => "archived" }`
119
+ and stays true when a new status value is added, which the `_in`
120
+ workaround could not.
121
+ - **Additive, not breaking.** Nothing that worked before is refused now;
122
+ the list only widens. No `UPGRADING.md` entry is needed on that
123
+ account (ADR 015), though the release notes should say the predicate
124
+ exists, the same way a new capability is always announced.
125
+ - The README and any documentation listing a dimension's allowed
126
+ predicates needs the two new entries alongside the existing four, kept
127
+ in sync with `CATEGORICAL_PREDICATES` rather than restated by hand.
128
+ - `reject_oversized_filters!` (ADR 025) already bounds any predicate
129
+ whose `Ransack::Predicate#wants_array` is true, which includes
130
+ `not_in`, so the 1000 value ceiling applies to it without a code
131
+ change.
132
+ - Test coverage belongs in `definition_query_test.rb` (the choke point
133
+ itself), `fixed_filter_test.rb` and `frame_default_filter_test.rb` (the
134
+ two callers ADR 040 and ADR 043 built), so all three layers are proven
135
+ rather than only the one that motivated this. Not built here.
136
+ - What would change this decision: a host with a measured need to
137
+ exclude a specific instant from a time dimension, which nothing today
138
+ shows evidence of. The answer then is the same rule ADR 025 already
139
+ set: add it to the list in an ADR that names the dashboard question,
140
+ not by making the list configurable.
@@ -0,0 +1,206 @@
1
+ ---
2
+ Date: 2026-09-29
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005, ADR 006, ADR 008, ADR 018, ADR 024, ADR 025, ADR 037, ADR 040
5
+ Triggers:
6
+ - making a line chart, or a time table row, a click source
7
+ - a click that has to write more than one filter condition
8
+ - deciding what a time pane does with a range filter on its own dimension
9
+ - writing a time range into the page URL or a pane src
10
+ - drill-down, or narrowing a time pane's granularity by clicking a bucket
11
+ Topics: time, cross-filtering, urls, ransack, accessibility, roadmap
12
+ ---
13
+
14
+ # ADR 045: Clicking a Time Bucket Filters the Frame to Its Range
15
+
16
+ ## Context
17
+
18
+ A time pane re-scopes when any other pane is clicked, but clicking a
19
+ bucket in it does nothing (#18). ADR 006 left that out deliberately:
20
+ "Clicking a category adds one Ransack condition, `status_eq=paid`.
21
+ Clicking a month means two, `placed_on_gteq` and `placed_on_lt`, and the
22
+ dashboard's filter model is a single key and value per toggle." It said
23
+ drill-down was the natural next decision and would get its own ADR. This
24
+ is that ADR, and it comes now rather than after 1.0 because of what ADR
25
+ 037 says 1.0 is: the pane URL shape and the dashboard filter parameters
26
+ stop moving. A range is a new shape in exactly that grammar, and adding
27
+ it after the freeze is a breaking change to the surface rather than an
28
+ addition to it. Nobody reading a dashboard would call a line chart that
29
+ ignores clicks finished, either. It is the first pane a person tries to
30
+ click.
31
+
32
+ Four things were measured against the dummy before deciding anything.
33
+
34
+ **Two conditions work as a range, and the end is exclusive.** Against the
35
+ `placed_on` date column, `placed_on_gteq=2026-09-01` with
36
+ `placed_on_lt=2026-09-02` returns the one order on that day, of four in
37
+ all. The month equivalent returns all four. `_gteq` and `_lt` are
38
+ already on a time dimension's allowed list (ADR 025), so the guard needs
39
+ nothing added. Using `_lteq` for the end would make the end of one bucket
40
+ also the start of the next, which is why the pair is half open.
41
+
42
+ **A bucket's label cannot be turned back into a range.** Groupdate's keys
43
+ are labelled after the query (ADR 006): a day is `2026-09-01`, a week is
44
+ its Monday `2026-08-31`, but a month is `Sep 2026`, a quarter is
45
+ `Q3 2026` and a year is `2026`. The chart looks its filter up by label
46
+ (`filters_for`), so the range has to be computed on the server from the
47
+ bucket before the key is turned into a label, not parsed out of the label
48
+ afterwards.
49
+
50
+ **A time pane collapses to the bucket that was clicked.** `Query
51
+ #applicable_filters` drops a categorical pane's own dimension from its
52
+ filters, so the pane still shows the alternatives to what was picked, but
53
+ returns every filter for a time pane. With a range on `placed_on`, the
54
+ time pane's own query returns `{"2026-09-01" => 1}`: one point on a line.
55
+ That is the whole difficulty. If the click wrote the range and nothing
56
+ else changed, the pane you clicked would become a single dot, and so
57
+ would every other time pane over the same dimension.
58
+
59
+ **A time zone offset is accepted on a date column.** `2026-09-01T00:00:00Z`
60
+ and `2026-09-01T00:00:00+10:00` both filter the date column to the right
61
+ day, in UTC and under `Australia/Brisbane`. Not measured, because the
62
+ dummy has no time dimension on a timestamp column and `created_at` is not
63
+ ransackable there: the same pair against a datetime column with
64
+ non-UTC `Time.zone`. Whoever builds this checks it first (see
65
+ Consequences).
66
+
67
+ ### What was considered
68
+
69
+ **Write one key, a range string.** `placed_on_between=2026-09-01..2026-09-02`
70
+ would keep the frame's one key and value per toggle. Rejected. Ransack
71
+ has no such predicate, so it would be a grammar Janela invents on top of
72
+ ADR 005's, which is built on `q[...]` a person can read and a host can
73
+ already write by hand (ADR 025). ADR 002 and CLAUDE.md are both against a
74
+ second query language, and ADR 006 already tells hosts they can pass
75
+ `placed_on_gteq` and `placed_on_lt` today.
76
+
77
+ **Have the click write keys of its own, so it never meets the host's.**
78
+ A `placed_on_clicked_from` pair, say, that a time pane ignores while it
79
+ keeps applying `_gteq` and `_lt`. Rejected as a second vocabulary for one
80
+ idea. The reader's own selection and a host's range are different
81
+ *sources*, and ADR 040 and ADR 043 already sort by source: a host's fixed
82
+ and default filters apply on a pane's own dimension, the reader's do not.
83
+ Time panes should follow that rule like every other pane, not carry a
84
+ private one.
85
+
86
+ **Narrow, the way a BI tool drills.** Clicking `Sep 2026` changes that
87
+ pane's granularity to weeks within September (#18's second option).
88
+ Rejected here, and deferred, not refused. It changes what one pane shows
89
+ rather than what the frame is filtered to, so it is a separate gesture
90
+ with its own question: how to get back out, and whether it belongs in the
91
+ URL (ADR 008). The filter is useful alone, and drill-down can be added
92
+ on top of it without changing anything decided below.
93
+
94
+ **Select more than one bucket.** Ctrl-click to add a second day, as ADR
95
+ 024 does for values. Rejected. Two `_gteq`/`_lt` pairs on one attribute
96
+ are not two ranges. Ransack ANDs its conditions, so two different days
97
+ return no rows at all, silently, which is the failure ADR 024 measured for
98
+ `_null` with `_in`. Ransack's `g[]` grouping could express an OR and is
99
+ rejected for the reason ADR 024 gave: it makes the URL unreadable.
100
+ Adjacent buckets could be merged into one longer range, but that is a
101
+ different gesture with a different rule about gaps, and nothing here asks
102
+ for it.
103
+
104
+ ## Decision
105
+
106
+ **Clicking a bucket on a time pane filters the frame to that bucket's
107
+ half-open range, written as the dimension's `_gteq` and `_lt`. A time
108
+ pane shows the alternatives to its own selection, like every other pane,
109
+ and highlights the buckets inside it.**
110
+
111
+ **The range is the bucket's start and the next bucket's start.** The
112
+ server computes both from the bucket Groupdate returned, at the pane's
113
+ granularity and with the week starting on `Date.beginning_of_week`, and
114
+ hands the chart a pair per label. A click on `Sep 2026` writes:
115
+
116
+ ```
117
+ q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-10-01
118
+ ```
119
+
120
+ For a date column the values are dates. For a timestamp they are the
121
+ start in `Time.zone`, ISO 8601 with its offset, so the bucket a person
122
+ clicked is the bucket the filter selects. The URL stays readable and
123
+ editable, and a range a host passes by hand behaves identically.
124
+
125
+ **The toggle carries a selection, and a selection can be more than one
126
+ condition.** A value click is a selection of one condition, as it is
127
+ today. A bucket click is a selection of two. The frame controller treats
128
+ either as one thing to add, replace or clear, so `clearDimension` clears
129
+ the dimension's `gteq`, `gt`, `lteq` and `lt` as well as `in`, `null` and
130
+ `eq`. Clicking the selected bucket clears it, the same as a value.
131
+
132
+ **A modifier click is a plain click on a time pane.** A range is not a
133
+ set, for the reason above, so Ctrl and Cmd change nothing here. This is
134
+ the one place ADR 024's gesture does not apply, and the pane says so in
135
+ its accessible description rather than leaving it to be discovered.
136
+
137
+ **A time pane ignores the reader's range on its own dimension, and
138
+ highlights the buckets that fall inside it.** This reverses what
139
+ `applicable_filters` does for time panes today and follows the rule ADR
140
+ 040 and ADR 043 already set: a host's fixed and default filters narrow a
141
+ pane on its own dimension, the reader's selection does not. A bucket is
142
+ selected when its whole range lies within the filter's, so clicking a
143
+ month highlights every day of that month on a day chart. Panes over the
144
+ same dimension at different granularities agree that way without knowing
145
+ about each other.
146
+
147
+ **Granularity does not change.** Drill-down stays open (#18's second
148
+ option), to be decided on top of this rather than folded into it.
149
+
150
+ **Keyboard follows ADR 018.** A time table's rows render their labels as
151
+ buttons, as a categorical table's do, and Enter is the click. A line
152
+ chart stays a mouse surface, as ADR 024 accepts for bars, and the table
153
+ that renders the same data is the operable one.
154
+
155
+ **A stored pane is still not clickable** (ADR 009). It is the record of a
156
+ moment.
157
+
158
+ ## Consequences
159
+
160
+ - A dashboard can answer "what happened in March?" by clicking March,
161
+ and every other pane re-scopes. That is the first thing anyone tries on
162
+ a line chart and it now works.
163
+ - **Breaking, narrowly.** A reader-supplied `placed_on_gteq` or `_lt` in a
164
+ URL used to narrow a time pane's own series. It now scopes every other
165
+ pane and leaves that one showing the whole series with the range
166
+ highlighted. A host's `where:` and `default_where` still narrow it, so
167
+ the supported way to fix a range is unchanged. It needs an
168
+ `UPGRADING.md` entry naming both routes, and the release carrying it is
169
+ a minor one (ADR 015). The doctor cannot see this from source, because
170
+ the filter arrives at runtime.
171
+ - `Query#clickable?` stops excluding time panes, `filter_params` and
172
+ `filters_for` return a pair for one, and `selected_values` for a time
173
+ pane becomes a question about ranges. `Definition#query` has to keep the
174
+ bucket start until after the labelling step, since the label is
175
+ lossy. These are the parts a forker is most likely to touch, and the
176
+ reason they are named here.
177
+ - The frame controller's toggle grows from one key and value to a
178
+ selection. A host that dispatches `janela--frame:toggle` itself keeps
179
+ working, because a single key and value is still a selection.
180
+ - The case measured in Context as unmeasured was measured in the build.
181
+ On SQLite under `Australia/Brisbane`, a timestamp at 14:30 UTC on 1
182
+ September was bucketed into 2 September, a `_gteq`/`_lt` pair written as
183
+ ISO 8601 with `+10:00` for 2 September returned it and the pair for 1
184
+ September returned nothing, and date-only strings gave the same answers.
185
+ Groupdate honoured the zone on SQLite there, which ADR 006 said it does
186
+ not, so that line of ADR 006 is out of date. Not measured on PostgreSQL
187
+ or MySQL, whose zone handling is the database's own.
188
+ - **The demo proves it.** Until a line pane on the home page's "This one
189
+ is live" frame filters the other panes when a day is clicked, and a
190
+ system test does the same, this ADR has been read but not shown to work.
191
+ The build includes both, and the home page's copy changes from "three
192
+ panes" to match.
193
+ - **This moves #18 into 1.0.** ADR 037 sorted #18 after 1.0 on the ground
194
+ that it "needs an ADR of its own before any code", and that ADR now
195
+ exists. What moves it is the ground ADR 037 itself argues: a range is a
196
+ new shape in the URL grammar the freeze covers. The milestone gains a
197
+ ninth issue, `docs/roadmap.md` (and the count in its illustration) is
198
+ updated with it, and ADR 037 is left as written. The sort in it was
199
+ right at the time, and an ADR records what was decided then.
200
+ - ADR 006's paragraph "Time panes are not yet click sources" is
201
+ superseded by this one. The rest of ADR 006 stands.
202
+ - What would change this decision: a real dashboard that needs two
203
+ separate periods selected at once, which would have to be an OR and
204
+ would have to justify the URL cost ADR 024 refused; or drill-down
205
+ turning out to be the gesture people expect from a click, in which case
206
+ the follow-up ADR may swap which one a plain click does.
@@ -0,0 +1,199 @@
1
+ ---
2
+ Date: 2026-09-29
3
+ Status: Accepted
4
+ Related: ADR 016, ADR 018, ADR 024, ADR 026, ADR 036, ADR 037, ADR 042
5
+ Triggers:
6
+ - adding a renderer, or a colour a renderer draws with
7
+ - adding a custom property to janela.css or documenting one in docs/theming.md
8
+ - drawing a part-to-whole split, a doughnut or a pie
9
+ - deciding what a chart does with more categories than it has colours
10
+ - a chart pane that must be readable, printable or operable without JavaScript
11
+ Topics: rendering, styling, theming, charts, accessibility, roadmap
12
+ ---
13
+
14
+ # ADR 046: A Ring Is Server Drawn SVG, and a Palette Is Eight Fixed Colours
15
+
16
+ ## Context
17
+
18
+ #30, raised from a real install: a two-category split has to be drawn as
19
+ two bars, and a ring reads better for a part-to-whole. It asks for
20
+ `doughnut` and `pie` renderers, a categorical palette to make them
21
+ readable, and two smaller fixes to the chart controller. It assumes
22
+ Chart.js, because the controller already hands its type straight to
23
+ it. ADR 037 puts it in 1.0 and #30 is the only item there that adds to
24
+ what a pane can be drawn as.
25
+
26
+ Measured against the demo before deciding:
27
+
28
+ - `renderer: "doughnut"` raises `Janela::BadRequest` today. The whitelist
29
+ is `Janela::Query::RENDERERS` (`%w[table bar line]`). The issue says
30
+ `Pane::RENDERERS`, which does not exist. The pane form, `Janela.renderers`,
31
+ the `janela.renderers.*` locale keys, the gallery and stored pane rows
32
+ all read that one constant.
33
+ - Chart.js is vendored with its doughnut and pie controllers, so the
34
+ canvas route needs no dependency.
35
+ - Switching a live bar chart's config to `doughnut` with the controller's
36
+ own options gives a ring in one colour: `backgroundColor` holds one
37
+ distinct value across four segments, so the boundaries are hairlines.
38
+ The unconditional `y` scale is configured on it too. Both complaints in
39
+ the issue are real.
40
+ - `--janela-accent` is the only colour Janela publishes
41
+ (`docs/theming.md`). Vitral's `--vitral-pane-1..5` are translucent glass
42
+ tints for backgrounds, not colours to draw data in.
43
+ - The demo's dimensions have four, two and three values (one of them the
44
+ `(none)` group). A categorical dimension is bounded only by 1000
45
+ (ADR 025), so a palette will meet more categories than it has colours.
46
+ - The dataviz palette validator (a fixed-order eight colour categorical
47
+ set) passes on adjacent pairs in light mode with the accent as slot 1:
48
+ worst adjacent colour vision separation 9.1, normal vision 19.6, against
49
+ targets of 8 and 15. It warns that four slots (accent, aqua, yellow,
50
+ pink) fall under 3:1 contrast against a light surface, which obliges
51
+ visible labels or a table view. The same eight hues stepped for a dark
52
+ surface pass too (8.4, 19.3, contrast all 3:1 or better).
53
+
54
+ ### What was considered
55
+
56
+ **Chart.js `doughnut` and `pie`, as the issue proposes.** The smallest
57
+ change: a whitelist entry, a palette, and conditionals around the scale
58
+ and legend. Rejected. ADR 026 makes server rendered HTML and CSS the
59
+ default, a library earning its place by doing what the document cannot,
60
+ and a ring is inline SVG arcs, which the document can do. A canvas is
61
+ blank without JavaScript, does not print as text, has nothing focusable
62
+ behind it (ADR 024 already says as much of bars) and puts the labels
63
+ inside a bitmap. It would also leave the issue's two "smaller things" as
64
+ work: the scale and legend switches exist only because the canvas has to
65
+ be told what it is.
66
+
67
+ **A CSS `conic-gradient` ring.** Also HTML and CSS, and shorter than SVG.
68
+ Rejected. A gradient is one element, so a slice has no hit target, no
69
+ native tooltip and no way to take a gap or a dimmed state of its own.
70
+ Every one of those would have to be rebuilt from the legend alone.
71
+
72
+ **Cycle the palette.** #30 says "N distinct colours cycling". Rejected.
73
+ The 9th slice reusing the 1st colour draws two different categories the
74
+ same, next to a legend that says they are different. The rule is fixed
75
+ order and a neutral past the last slot.
76
+
77
+ **Leave bars on the one accent.** The recommendation put to the
78
+ maintainer, to avoid changing panes people have already themed.
79
+ Overridden: the palette applies to bars as well. See Consequences for
80
+ what that changes and what a host does.
81
+
82
+ ## Decision
83
+
84
+ **`doughnut` and `pie` are renderers drawn on the server as inline SVG
85
+ with a legend that is a table of buttons, and Janela publishes a
86
+ categorical palette of eight fixed colours that bars, doughnuts and pies
87
+ draw with.**
88
+
89
+ **The ring is SVG, and its legend is the control.** Each slice is a `path`
90
+ carrying the same `janela--frame#toggle` action a table button does, a
91
+ `<title>` child so a hover names it, and a gap between slices so no
92
+ boundary depends on colour alone. A doughnut is a pie with a hole. The
93
+ pane is a `figure` with a `figcaption` title (ADR 042), the SVG points to
94
+ it, and beside the ring is a legend of real buttons, each with a swatch,
95
+ the label and the measure's formatted value (ADR 020). The legend is what a
96
+ keyboard, a screen reader and a touch user operate, and it is the visible
97
+ labelling the palette's contrast warning obliges. Ctrl and Cmd add and
98
+ remove as in ADR 024, because they are the same buttons. It works,
99
+ printed and unstyled, with no JavaScript, and the engine's own frame page
100
+ draws it (ADR 026, rule 3).
101
+
102
+ **A ring draws only what a ring can.** A slice with a zero value is left
103
+ out of the ring and stays in the legend. A pane with a negative value is
104
+ drawn as the table renderer's markup instead, since a part of a whole
105
+ cannot be negative, and the pane says so in words rather than drawing
106
+ something wrong. Nothing raises.
107
+
108
+ **The palette is eight ordered custom properties and a neutral.**
109
+
110
+ ```css
111
+ :root {
112
+ --janela-series-1: var(--janela-accent);
113
+ --janela-series-2: #eb6834;
114
+ --janela-series-3: #1baf7a;
115
+ --janela-series-4: #eda100;
116
+ --janela-series-5: #e87ba4;
117
+ --janela-series-6: #008300;
118
+ --janela-series-7: #4a3aa7;
119
+ --janela-series-8: #e34948;
120
+ --janela-series-other: #8c8c8c;
121
+ }
122
+ ```
123
+
124
+ Slot 1 is the accent, so a host that has set `--janela-accent` still has
125
+ its own colour on the first bar. The set is the reference instance of the
126
+ validated palette with the accent standing in for its blue, and it was
127
+ run through the validator in that form. A colour is chosen by a
128
+ category's position in the result, first to eighth, and every category
129
+ past the eighth takes `--janela-series-other`. Never cycled. A host
130
+ that wants a longer pane says `limit: 8` (ADR 007), which is the honest
131
+ answer for a ring anyway, and the documentation says a ring suits few
132
+ slices. Lines are one series and keep the accent.
133
+
134
+ **The selected state is the existing one, over each slice's own colour.**
135
+ With nothing selected every slice and bar is solid. With a selection the
136
+ unselected ones dim to the same reduced opacity a bar already uses
137
+ (`colours()` in `chart_controller.js`) and the selected ones stay solid.
138
+ A dimmed set of eight colours stays distinguishable because opacity is
139
+ applied to the slice, not the palette.
140
+
141
+ **Published, and therefore frozen at 1.0.** `--janela-series-1..8`,
142
+ `--janela-series-other` and the ring's and legend's class names join the
143
+ contract in `docs/theming.md` in the same change (ADR 036). The class
144
+ names are chosen in the build and documented there, not here. The dark
145
+ values that passed the validator are recorded in `docs/theming.md` for a
146
+ theme to use. `janela.css` ships no dark scheme today (ADR 023 leaves that
147
+ to a theme), and adding one is not this decision.
148
+
149
+ **The chart controller changes only for bars.** It draws bar and line, so
150
+ the two conditionals in the issue (`beginAtZero` on an axis-less chart and
151
+ the hidden legend) do not arise and are not added. Bars read their
152
+ per-bar colours from the palette on the element, the way `accentColour`
153
+ already reads `--janela-accent`.
154
+
155
+ ## Consequences
156
+
157
+ - A part-to-whole split has a renderer, and it is a table to a screen
158
+ reader, a picture to everyone else, and a link on a slice to anyone with
159
+ a mouse.
160
+ - `Query::RENDERERS` becomes `table bar line doughnut pie`. `Janela
161
+ .renderers`, the pane form, the locale keys (`janela.renderers.doughnut`
162
+ and `.pie`), the gallery and its tests follow from that one constant and
163
+ need entries. A model with no suitable dimension shows the renderer as
164
+ unavailable, as the gallery does for any other. Additive.
165
+ - **Bars change appearance, in a minor release.** Every existing bar pane
166
+ goes from one accent colour to one colour per bar. A host that themed
167
+ bars through `--janela-accent` keeps that colour on the first bar and
168
+ the accent for selection and hover, and gets new colours on the rest.
169
+ A host that wants the old look sets `--janela-series-2` to
170
+ `--janela-series-8` to the accent. It needs an `UPGRADING.md` entry
171
+ saying exactly that, in the release that carries it (ADR 015). The
172
+ doctor cannot see this from source.
173
+ - **Colour follows position, not the category.** A filter on another
174
+ pane can reorder this pane's categories, and a colour then moves with
175
+ its rank. The dataviz rule that colour follows the entity, so a filter
176
+ never repaints the survivors, is not met, because Janela has no registry
177
+ of what a category is. The label is always beside the colour (legend or
178
+ axis), which is the reason this is acceptable, and the cost is real. What
179
+ would change it: a host declaring a colour for a dimension value,
180
+ which is a new piece of DSL and its own ADR.
181
+ - Four slots fall under 3:1 contrast on a light surface. The legend is the
182
+ visible labelling the validator asks for, and a bar has its axis.
183
+ Adjacent colour vision separation is 9.1 and above, but only the first
184
+ three slots pass when every pair is compared, and a ring with a fifth
185
+ slice puts colours side by side that are not adjacent in the palette.
186
+ That is why the gap and the legend exist and why the documentation
187
+ recommends few slices.
188
+ - A slice's hit target is small when it is a small share. The legend row
189
+ is always the full target.
190
+ - The demo proves it. The home page's "This one is live" frame gains a
191
+ doughnut pane (revenue by status), the gallery shows both new renderers
192
+ through `Janela.renderers`, and a system test clicks a slice and a legend
193
+ button and sees the other panes re-scope. The copy that says how many
194
+ panes there are changes with it, once, together with the time pane from
195
+ ADR 045.
196
+ - What would change this decision: a chart that needs what SVG cannot do,
197
+ such as animation or many thousands of marks, is the case ADR 026 keeps
198
+ the library for, and this one does not fit it. A host that needs a
199
+ category to keep its colour across filters is the case above.
@@ -22,27 +22,27 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
22
22
  |-------|------|
23
23
  | **Vision, scope, forkability** | 001, 010, 012, 037 |
24
24
  | **Open-source & host-decoupling** | 001, 022, 036, 041 |
25
- | **DSL & query layer** | 002, 006, 007, 020, 025, 038 |
25
+ | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044 |
26
26
  | **Dependencies** | 002, 003, 004, 006, 017, 025 |
27
27
  | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040 |
28
28
  | **Performance & storage** | 007, 017, 025 |
29
29
  | **Ordering & formatting** | 007, 020, 038 |
30
- | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043 |
30
+ | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045 |
31
31
  | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039 |
32
- | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042 |
33
- | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043 |
32
+ | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046 |
33
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044 |
34
34
  | **Naming rule** | 014, 023, 036 |
35
- | **JavaScript delivery & charts** | 004, 006, 026, 042 |
36
- | **Time dimensions** | 006, 025 |
37
- | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041 |
35
+ | **JavaScript delivery & charts** | 004, 006, 026, 042, 046 |
36
+ | **Time dimensions** | 006, 025, 045 |
37
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045 |
38
38
  | **Snapshots & publishing** | 009, 020, 028, 033, 034 |
39
39
  | **AI agents & guidance** | 010, 015, 021 |
40
40
  | **The doctor & checks** | 021, 025, 032, 033, 035 |
41
41
  | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042 |
42
- | **Accessibility & keyboard** | 024, 042 |
43
- | **Security** | 003, 025, 028, 031, 032, 034, 035 |
42
+ | **Accessibility & keyboard** | 024, 042, 045, 046 |
43
+ | **Security** | 003, 025, 028, 031, 032, 034, 035, 044 |
44
44
  | **Testing** | 003 |
45
- | **Roadmap & planning** | 001, 037 |
45
+ | **Roadmap & planning** | 001, 037, 045, 046 |
46
46
 
47
47
  ## Chronological
48
48
 
@@ -91,7 +91,10 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
91
91
  | 041 | A Host Finds Its Frame by Owner and Key | 2026-09-24 | Accepted |
92
92
  | 042 | A Chart's Title Is a Figcaption, and the Canvas Points to It | 2026-09-28 | Accepted |
93
93
  | 043 | A Frame's Default Filter Names the Model It Narrows | 2026-09-28 | Accepted |
94
+ | 044 | A Categorical Dimension Can Say What It Excludes, Not Only What It Includes | 2026-09-29 | Accepted |
95
+ | 045 | Clicking a Time Bucket Filters the Frame to Its Range | 2026-09-29 | Accepted |
96
+ | 046 | A Ring Is Server Drawn SVG, and a Palette Is Eight Fixed Colours | 2026-09-29 | Accepted |
94
97
 
95
98
  ## Next number
96
99
 
97
- Next ADR: 044
100
+ Next ADR: 047