janela 0.8.0 → 0.9.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +33 -2
  4. data/UPGRADING.md +48 -0
  5. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  6. data/app/assets/stylesheets/janela.css +16 -8
  7. data/app/controllers/janela/application_controller.rb +7 -0
  8. data/app/controllers/janela/panes_controller.rb +22 -7
  9. data/app/controllers/janela/queries_controller.rb +2 -1
  10. data/app/helpers/janela/frames_helper.rb +26 -7
  11. data/app/models/janela/frame.rb +20 -0
  12. data/app/models/janela/pane.rb +67 -4
  13. data/app/models/janela/query.rb +7 -2
  14. data/app/views/janela/frames/_content.html.erb +26 -0
  15. data/app/views/janela/frames/_frame.html.erb +9 -1
  16. data/app/views/janela/frames/_pane.html.erb +2 -2
  17. data/app/views/janela/panes/_content_form.html.erb +33 -0
  18. data/app/views/janela/panes/_row.html.erb +1 -1
  19. data/app/views/janela/panes/edit.html.erb +5 -1
  20. data/app/views/janela/panes/new.html.erb +6 -1
  21. data/app/views/janela/queries/_query.html.erb +15 -11
  22. data/config/locales/en.yml +5 -0
  23. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  24. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  25. data/docs/composing.md +269 -0
  26. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  27. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  28. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  29. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  30. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  31. data/docs/decisions/INDEX.md +18 -12
  32. data/docs/multi-tenancy.md +22 -0
  33. data/docs/naming.md +7 -0
  34. data/docs/roadmap.md +129 -8
  35. data/docs/theming.md +17 -15
  36. data/lib/janela/definition.rb +8 -0
  37. data/lib/janela/version.rb +1 -1
  38. metadata +25 -17
@@ -0,0 +1,168 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 009, ADR 012, ADR 016, ADR 036, ADR 037
5
+ Triggers:
6
+ - putting a heading, a paragraph, a link, an icon or an image on a stored frame
7
+ - adding a kind of pane that is not a query
8
+ - letting an analyst's text reach a page
9
+ - storing HTML, Markdown or a template in a row
10
+ - deciding whether a frame draws its own name
11
+ Topics: frames, panes, persistence, layout, authorisation, html
12
+ ---
13
+
14
+ # ADR 039: A Pane Can Hold Words, and Only Code Writes Markup
15
+
16
+ ## Context
17
+
18
+ ADR 012 made a dashboard data so that its author, the analyst, can
19
+ change it without a deploy. It gave the analyst one thing to arrange:
20
+ a pane that names a measure the model declared. That was the whole
21
+ vocabulary, and it is why a stored frame is safe to hand over. A row
22
+ cannot invent a query, reach a model nobody exposed, or put anything on
23
+ the page that a developer did not write.
24
+
25
+ A real dashboard is more than query results. The first one a host asked
26
+ about had, above its numbers, a heading with an icon, a count of done
27
+ out of total, a percentage, a "view all" link and a progress bar. A
28
+ block form frame can have all of it, because the page around the panes
29
+ is the host's own ERB (docs/composing.md). A stored frame can have none
30
+ of it. `janela/frames/_frame` renders `frame.panes` and nothing else,
31
+ and `Janela::Pane` requires a `measure` and validates it against a
32
+ `janela` block, so there is no row a heading could be. The frame's
33
+ `name` is never drawn either. The analyst who owns the dashboard can
34
+ choose every number on it and cannot label them.
35
+
36
+ So the question is not whether a stored frame should hold content. It
37
+ has to, or the frame the analyst owns is always the unlabelled half of
38
+ the page. The question is what an analyst may put in a row without
39
+ breaking the property ADR 012 was built on.
40
+
41
+ ### What was considered
42
+
43
+ **Stored HTML**, a column of markup rendered as it is, was rejected.
44
+ It is the one thing ADR 012 exists to prevent: an analyst's row would
45
+ put arbitrary markup on a page that every other reader of that frame
46
+ loads, which is stored cross site scripting with a dashboard for a
47
+ delivery mechanism. Running it through Rails' `sanitize` narrows the
48
+ tags but not the problem. The allowlist becomes a security boundary
49
+ Janela has to maintain, and what survives it, links anywhere and
50
+ styling that imitates the host's own chrome, is still content a
51
+ developer did not write appearing as if they had.
52
+
53
+ **Markdown** was rejected for the same reason one step removed. It
54
+ compiles to HTML, it needs a parser as a dependency (ADR 001 is spare
55
+ with those), and a link in it can point anywhere. What an analyst
56
+ actually needs from it, a heading and a paragraph, is two fields.
57
+
58
+ **A sandboxed iframe**, `<iframe srcdoc sandbox="allow-scripts">`, was
59
+ considered because an application built on this gem already renders
60
+ agent-authored slides that way, and it works: the sandbox gives the
61
+ content an opaque origin, so it cannot read the page or its cookies.
62
+ It was rejected for a public gem. The surface is large, the
63
+ guarantees depend on attributes a host can loosen, and an iframe does
64
+ not size to its content, so it cannot sit in a CSS grid beside a pane
65
+ without fixed heights, which is exactly what #24 is trying to remove
66
+ for charts. It stays available to any host that wants it, in its own
67
+ code, around a frame.
68
+
69
+ **Drawing the frame's name as a heading** was rejected as the whole
70
+ answer. It would change every existing frame's appearance on upgrade,
71
+ it gives one heading per frame when a dashboard often wants one per
72
+ row, and it still leaves no icon, no paragraph and no link.
73
+
74
+ **A separate table of content rows** beside `janela_panes` was
75
+ rejected because position is the frame's single ordering. Two tables
76
+ would need a shared position sequence across them, and ADR 012 kept
77
+ one level between a frame and what it shows on purpose.
78
+
79
+ ## Decision
80
+
81
+ **A pane can hold words instead of a query. An analyst writes text
82
+ into a row and Janela escapes it; anything that needs markup is a
83
+ partial the host wrote in code, which the analyst places by name.**
84
+
85
+ This is ADR 012's division of labour applied to content: developers
86
+ define what can be shown, analysts arrange it and write the words.
87
+
88
+ **A pane gains a `kind`.** `query` is today's pane and the default, so
89
+ every existing row is unchanged. Two more kinds:
90
+
91
+ - **`text`**: a `heading`, a `body` and an optional `link`, each a
92
+ plain string. Janela renders the heading as a heading, the body as
93
+ paragraphs split on blank lines, and the link as an anchor, and
94
+ escapes all three. No markup an analyst types reaches the page as
95
+ markup.
96
+ - **`partial`**: a `partial` name, plus the same `heading`, `body` and
97
+ `link` passed to it as locals. The partial is the host's, under
98
+ `app/views/janela_content/`, so the host writes the icon, the image,
99
+ the layout and the look, and the analyst writes the words that go in
100
+ it. A name must match `/\A[a-z0-9_]+\z/` and a partial must exist in
101
+ that directory, so a row cannot reach any other template. There is
102
+ no registry and no setting: the directory is the allowlist, the way
103
+ a controller's views directory already is. It sits outside
104
+ `app/views/janela/` on purpose. A host view at an engine's path
105
+ replaces the engine's own, and `janela/panes/` already holds the
106
+ engine's `_form` and `_row`, so a content partial named `form` there
107
+ would silently replace the pane editing form.
108
+
109
+ ```ruby
110
+ frame.panes.create!(kind: "text", heading: "Refunds are excluded",
111
+ body: "Figures are in the store's own currency.")
112
+ frame.panes.create!(kind: "partial", partial: "overview_heading",
113
+ heading: "Project overview", link: "/cards", span: 3)
114
+ ```
115
+
116
+ A `text` or `partial` pane has no `model` or `measure`. Those columns
117
+ become nullable, and a validation requires them for `query` and
118
+ refuses them for the other two, so the database cannot hold a
119
+ half-query.
120
+
121
+ **A link is a path, not a URL.** `link` must start with `/`, and not
122
+ `//`, so an analyst can point readers at another page of the same
123
+ application and cannot send them to another site. A host that wants an
124
+ external link writes it in a partial.
125
+
126
+ **Images and icons are the host's.** Janela stores no files and ships
127
+ no icon set (ADR 036). A heading with an icon is a partial that draws
128
+ the icon beside the heading it is given. An image is a partial that
129
+ reads it from wherever the host already keeps images. Janela does not
130
+ take a dependency on Active Storage to do something a host's own
131
+ partial already can.
132
+
133
+ **A content pane sits in the grid like any other.** It spans columns,
134
+ it is ordered by `position`, it is drawn as a `janela-pane`, and under
135
+ vitral it is a pane of glass. It does not take part in cross filtering:
136
+ it has no query, so the frame controller has no `src` to rewrite, and a
137
+ content pane renders inline rather than inside a turbo frame.
138
+
139
+ ## Consequences
140
+
141
+ - An analyst can label a stored frame, explain it and link out of it
142
+ without a deploy, which is the half of ADR 012 that was missing.
143
+ - The property ADR 012 stated plainly still holds, extended: a row can
144
+ name only a declared measure, an escaped string, a same-site path, or
145
+ a partial a developer wrote. Nothing an analyst types is markup.
146
+ - `janela_panes` gains `kind`, `heading`, `body`, `link` and `partial`,
147
+ and `model` and `measure` become nullable. That is a migration a host
148
+ has to install, so it needs an `UPGRADING.md` entry (ADR 015). The
149
+ engine's own forms need a way to add each kind.
150
+ - The pane partial becomes a branch on `kind`. `_pane.html.erb` is the
151
+ smallest file this touches and should stay that way: one partial per
152
+ kind under `janela/frames/`, chosen by name.
153
+ - Snapshots store results, not HTML (ADR 009). A snapshot is a list of
154
+ pane results with no frame behind it, so a content pane has no result
155
+ to store and is not in one. Words that explain a signed off figure
156
+ have to live in whatever page shows the snapshot. If a snapshot ever
157
+ needs to carry the text that sat beside its numbers on the day, that
158
+ is a separate decision.
159
+ - The pane class names a theme may target (ADR 036) gain one for the
160
+ content kinds, `janela-content`, and it goes into docs/theming.md in
161
+ the same change.
162
+ - Not in 1.0 (ADR 037). It adds to the public surface rather than
163
+ changing it, so it does not block a stable release and can land
164
+ before or after one.
165
+ - What would change this decision: a host that genuinely needs an
166
+ analyst to author markup, with a real reason a partial cannot serve.
167
+ The sandboxed iframe is the fallback that case would reopen, and this
168
+ record says why it was set aside.
@@ -0,0 +1,145 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 008, ADR 012, ADR 024, ADR 025, ADR 030, ADR 032, ADR 034
5
+ Triggers:
6
+ - showing a frame on a record's own page, filtered to that record
7
+ - reusing one frame across many records
8
+ - carrying a host's filter in q[...] and finding it cleared
9
+ - deciding whether a filter is a view choice or an authorisation boundary
10
+ - changing how a pane URL is built, or what clear() and toggle() touch
11
+ Topics: frames, filters, cross-filtering, urls, authorisation
12
+ ---
13
+
14
+ # ADR 040: A Host Can Fix a Frame's Filter, and No Click Removes It
15
+
16
+ ## Context
17
+
18
+ A host wants the same dashboard on every record's page, narrowed to
19
+ that record: a frame of the work in a queue, shown at the top of each
20
+ queue's page. Since ADR 012 a frame is a row an analyst edits, so the
21
+ panes are the same everywhere and only the record changes. Janela has
22
+ no way to say "this frame, for this record" (#57).
23
+
24
+ The only filter Janela knows is the one in the page URL, `q[...]`
25
+ (ADR 008). So a host redirects the record's page to carry one,
26
+ `/queues/42?q[queue_id_eq]=42`, and the panes read it. Measured on the
27
+ demo with `q[status_eq]=paid` standing in for the host's filter, it
28
+ does not hold:
29
+
30
+ - Opened with the filter, the revenue pane read $469,097.85 and every
31
+ pane's `src` carried `q[status_eq]=paid`.
32
+ - **Clear filters**, the button a dashboard shows, took it off. Revenue
33
+ read $664,639.01, the whole table, and the page URL lost the
34
+ parameter.
35
+ - **Escape**, with focus inside the frame, did the same.
36
+ - A click on a `status` value would replace it, because `toggle()`
37
+ clears every predicate on the dimension it is changing (ADR 024).
38
+
39
+ None of this is a bug in the frame controller. `q[...]` is Janela's by
40
+ design: `stripFilters` removes every `q[` key before writing the
41
+ current selection, and `clear()` empties the frame's filters, because
42
+ the selection is the reader's to change. A host's filter placed there
43
+ becomes the reader's to change too. On a record's page the reader then
44
+ sees every record their scope allows under that record's heading.
45
+ Nothing leaks, since `policy_scope` still bounds every pane (ADR 032).
46
+ The numbers are wrong, and they look right.
47
+
48
+ ### What was considered
49
+
50
+ **Making `clear()` leave the host's keys alone** was rejected. The
51
+ controller cannot tell a host's `q[queue_id_eq]` from a reader's link
52
+ that carries the same key, and ADR 008 means a shared link carries
53
+ exactly that. Any rule for telling them apart would be a second
54
+ convention inside one parameter.
55
+
56
+ **The host's `policy_scope`** was rejected. It answers who may see
57
+ which rows, and it runs in the engine's pane requests, which know
58
+ nothing about the page that holds the frame. Putting a page's record
59
+ into it would make authorisation depend on which page a pane was
60
+ loaded from, and ADR 032's checks would be reasoning about a scope that
61
+ changes per page.
62
+
63
+ **Storing the filter on the frame**, a `filters` column on
64
+ `janela_frames`, was rejected as the answer to this case. The case is
65
+ one frame reused across many records, so the filter belongs to the
66
+ render, not to the row. A frame that should always be narrowed the
67
+ same way is a smaller, different case, and it can be built on this
68
+ later.
69
+
70
+ **Signing the filter**, carrying it as a `MessageVerifier` token so a
71
+ reader cannot edit it, was rejected. It would imply a security
72
+ guarantee this is not. A reader who edits the fixed filter out of a
73
+ pane URL sees rows `policy_scope` already lets them see, which they
74
+ could see on any other dashboard. Signing adds a key to rotate and a
75
+ failure mode to explain, to protect nothing that authorisation does
76
+ not already protect.
77
+
78
+ ## Decision
79
+
80
+ **A host passes a filter when it renders a frame, and Janela applies it
81
+ to every pane beneath the reader's selection, where no click, clear or
82
+ link can remove it.**
83
+
84
+ ```erb
85
+ <%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
86
+
87
+ <%= janela_frame where: { queue_id_eq: @queue.id } do %>
88
+ <%= janela_pane Card, :count, by: :status %>
89
+ <% end %>
90
+ ```
91
+
92
+ `where:` is the name `Definition#query` already uses for the same
93
+ thing, Ransack conditions narrowing a relation, so a host learns one
94
+ word for it.
95
+
96
+ **It travels in the pane's base URL under its own key, `where[...]`.**
97
+ The helper writes it into each pane's `data-janela-src`, the base the
98
+ frame controller builds every request from, and into the first
99
+ render's `src`. The controller never reads or writes `where[...]`:
100
+ `stripFilters` removes only `q[` keys, `clear()` empties only the
101
+ frame's filters, and `toggle()` changes only `q[`. So the fixed filter
102
+ survives every click without the controller learning anything about
103
+ it. It is not written into the page URL, which already says what the
104
+ record is.
105
+
106
+ **The server applies it first and separately.** The pane endpoints
107
+ read `params[:where]` and `params[:q]` and narrow the relation twice,
108
+ fixed filter first, so the reader's selection can only narrow further.
109
+ The same key in both is two conditions ANDed, never one replacing the
110
+ other. The fixed filter goes through `Definition#filter`, so it is
111
+ bounded exactly as `q[...]` is: only declared dimensions, only the
112
+ predicates ADR 025 allows for each kind, and no more than 1000 values.
113
+ A host cannot fix a filter on a column it has not declared, which is
114
+ the same rule a reader already meets.
115
+
116
+ **It is a view filter, not an authorisation boundary, and the
117
+ documentation says so in those words.** It is visible in every pane's
118
+ URL, and a reader can remove it by editing one. What they reach by
119
+ doing that is what `policy_scope` already allows. A host that needs a
120
+ reader not to see other records writes that in its scope, where it
121
+ applies to every pane on every page.
122
+
123
+ ## Consequences
124
+
125
+ - One frame serves every record's page. An analyst edits it once, and
126
+ the host needs no column pointing at a frame per record.
127
+ - A host that carries its filter in `q[...]` today, by redirect, moves
128
+ it to `where:` and drops the redirect. Nothing breaks if it does not
129
+ move; the old way keeps its old weakness. The guide for this goes in
130
+ docs/composing.md.
131
+ - The pane URL shape gains `where[...]`. ADR 037 names the pane URL as
132
+ part of the surface that stops moving at 1.0, so this is better
133
+ decided before 1.0 than after, and it is additive: every existing URL
134
+ means what it meant.
135
+ - A snapshot is taken with the filters its caller passes (ADR 009,
136
+ ADR 034), not from a rendered page. A host snapshotting a record's
137
+ frame passes the fixed filter in `filters:`, and nothing does that
138
+ for it. Worth a line in the snapshot documentation, not a mechanism.
139
+ - The dimension the host fixes should not also be offered as a
140
+ clickable pane. Clicking it would AND a second condition on the same
141
+ column, and any value but the fixed one returns nothing. That is
142
+ correct and confusing, so the guide says not to.
143
+ - What would change this decision: a host that needs the fixed filter
144
+ hidden from the reader for a reason `policy_scope` cannot express.
145
+ That would reopen signing, and this record says why it was left out.
@@ -0,0 +1,112 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 012, ADR 013, ADR 014, ADR 019, ADR 040
5
+ Triggers:
6
+ - giving a record of the host's its own frame
7
+ - finding a frame from code without storing its id
8
+ - an owner that has more than one frame
9
+ - adding a slug, a handle or any second identifier to a frame
10
+ Topics: frames, persistence, naming, host-integration
11
+ ---
12
+
13
+ # ADR 041: A Host Finds Its Frame by Owner and Key
14
+
15
+ ## Context
16
+
17
+ A host that shows a frame on one of its own pages has to find that
18
+ frame from code. ADR 012 made frames rows an analyst edits, and ADR 040
19
+ made one frame usable across many records, so the host needs a way to
20
+ say "the frame for this" without a deploy creating it and without
21
+ remembering an id.
22
+
23
+ The owner looked like the answer. It is a nullable polymorphic
24
+ reference that Janela never reads (ADR 014, ADR 019), so a host can
25
+ point it at the record the frame is for, and
26
+ `Janela::Frame.find_or_create_by!(owner: queue)` is a frame's `dom_id`:
27
+ two columns, nothing added to the host's schema.
28
+
29
+ It holds for exactly one frame per owner, and owners rarely have one. A
30
+ tenant owns every frame an analyst makes from the engine's New button,
31
+ through `janela_frame_owner`, and a host wants frames of its own for
32
+ particular pages on top of those. `find_by(owner: account)` then returns
33
+ whichever row the database gives first. On a real installation it
34
+ returned the wrong frame: the first the tenant had ever made, not the
35
+ one the page was for (#59).
36
+
37
+ ### What was considered
38
+
39
+ **`name` as the key** was rejected. It is the analyst's to rename, so a
40
+ host looking a frame up by name loses it the day someone edits the
41
+ title.
42
+
43
+ **A column in the host's table** pointing at the frame was rejected as
44
+ the answer the owner was meant to save a host from. It needs a
45
+ migration on every table whose records want a frame, and a second one
46
+ for each extra frame per record.
47
+
48
+ **A slug**, which ADR 013 turned down, is not what is being asked for,
49
+ and the difference is the reason this is allowed. ADR 013 rejected a
50
+ slug as a way to *address* a frame: in a URL a person reads, derived
51
+ from the name, needing uniqueness rules, reserved words and regeneration
52
+ on rename, and a second lookup path to keep working. What a host needs
53
+ is none of those. It is an identifier the host writes in its own code,
54
+ never in a URL, never derived from the name, never shown to an analyst,
55
+ and never changed once set.
56
+
57
+ **Janela choosing the frame for a record**, a registry mapping host
58
+ classes to frames, was rejected as configuration for something one line
59
+ of the host's code already says.
60
+
61
+ ## Decision
62
+
63
+ **A frame may carry a `key` the host sets. A frame is found by its
64
+ owner and its key together, and each owner has at most one frame per
65
+ key.**
66
+
67
+ ```ruby
68
+ Janela::Frame.for(account, :overview) # the tenant's overview
69
+ Janela::Frame.for(queue, :analytics) # this queue's frame
70
+ Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
71
+ ```
72
+
73
+ `for` finds the frame with that owner and key, or creates it. The block
74
+ runs only when it creates one, to set what the host wants a new frame to
75
+ start as; a new frame is named after its key unless the block says
76
+ otherwise. The owner may be `nil` for a host with one tenant.
77
+
78
+ **The key is the host's and nobody else's.** Janela reads it only in
79
+ `for`. It is not in any route and not in the engine's forms, so an
80
+ analyst can rename, regrid and recompose a keyed frame without being
81
+ able to detach it from the page that finds it. A frame without a key,
82
+ which is every frame an analyst makes, is unchanged.
83
+
84
+ **Uniqueness is per owner, in the database.** A unique index on owner
85
+ and key, so two requests creating the same frame at once end with one
86
+ row, and `for` retries the find when the insert loses that race. Frames
87
+ with no key are not constrained by it.
88
+
89
+ `key` is a short lowercase string matching `/\A[a-z0-9_]+\z/`, the
90
+ shape of the symbols a host will pass, so `:analytics` and `"analytics"`
91
+ are the same key and nothing that looks like a URL or a name gets in.
92
+
93
+ ## Consequences
94
+
95
+ - A host gives any record any number of frames with no column of its
96
+ own: owner and key are the `dom_id`, and the key is the suffix
97
+ `dom_id(queue, :analytics)` would carry.
98
+ - `janela_frames` gains `key` and a unique index, so a migration and an
99
+ `UPGRADING.md` entry (ADR 015). Existing frames keep a nil key.
100
+ - The owner now does two jobs for a host that uses `for`: tenancy for
101
+ the policy scope, and which record a frame belongs to. ADR 019 said
102
+ Janela never reads the owner; `for` queries by it, and only there. A
103
+ host whose owner is a record rather than its tenant has to make its
104
+ policy scope see frames owned by its own records, which the
105
+ multi-tenancy guide shows.
106
+ - A frame deleted from the engine's pages is created again, empty, the
107
+ next time its page is visited. That is the right answer for a frame a
108
+ page depends on, and it is worth a line in the README so it is not a
109
+ surprise.
110
+ - What would change this decision: a host needing to find a frame by
111
+ something that is not stable in its own code. That is the addressing
112
+ problem ADR 013 decided, and it would be decided there again.
@@ -0,0 +1,174 @@
1
+ ---
2
+ Date: 2026-09-28
3
+ Status: Accepted
4
+ Related: ADR 016, ADR 018, ADR 024, ADR 026, ADR 036
5
+ Triggers:
6
+ - adding a title, caption or visible label to any renderer
7
+ - changing what a chart pane's markup contains, or what carries its classes
8
+ - extending or reading `.janela-own-headings`
9
+ - deciding what a theme may target in `janela.css` (ADR 036)
10
+ - a chart type that cannot sit inside a figure the way bar and line do
11
+ Topics: rendering, charts, accessibility, styling, public-api, theming
12
+ ---
13
+
14
+ # ADR 042: A Chart's Title Is a Figcaption, and the Canvas Points to It
15
+
16
+ ## Context
17
+
18
+ #61 found that a chart pane (`renderer: bar` or `renderer: line`) never
19
+ shows its title to a sighted reader. `janela/queries/_query.html.erb`
20
+ renders it as a bare `<canvas role="img" aria-label="<%= query.title
21
+ %>">`, and `chart_controller.js` passes the same string only into the
22
+ dataset's `label`, with the legend that would draw it turned off. A
23
+ table pane's `<caption>` and a single value pane's `<span
24
+ class="janela-value-label">` both render the same `query.title` as
25
+ visible text. A chart pane is the one renderer that does not.
26
+
27
+ This is not new. #26's own investigation, closed and shipped in 0.8.0,
28
+ already found and recorded the same fact while building
29
+ `.janela-own-headings`:
30
+
31
+ > The table's and the single value's are visible ... The chart's is
32
+ > not ... A chart pane is already announced without being seen.
33
+
34
+ #26 used that as the *model* the other two renderers should be brought
35
+ into line with for hiding purposes, not as a gap to close, and the
36
+ comment above `.janela-own-headings` in `janela.css` still says so:
37
+ "a chart pane needs nothing: its title was only ever an aria-label."
38
+ That sentence is accurate about the code and wrong about the design.
39
+ Nobody decided a chart should be the one renderer with no visible
40
+ title; it fell out of `legend: { display: false }` turning off the
41
+ only thing that would have drawn it.
42
+
43
+ ### Why the issue's own fix does not hold up
44
+
45
+ #61 proposes `plugins.title: { display: true, text: this.titleValue }`,
46
+ Chart.js's own title option. It works, and it was rejected, for three
47
+ reasons that all come from the same place: the text would live inside
48
+ the canvas bitmap rather than in the document.
49
+
50
+ - **It cannot be hidden by `.janela-own-headings`.** That rule hides
51
+ the table's `<caption>` and the value's `<span>` visually while
52
+ leaving them in the accessibility tree, by targeting real elements
53
+ with CSS (#26). A host with its own heading above a chart pane would
54
+ have no way to suppress the canvas-drawn one short of reconfiguring
55
+ Chart.js, so the one mechanism #26 shipped for this exact problem
56
+ would not cover the one renderer it was modelled on.
57
+ - **It contradicts ADR 026 directly**, not just in spirit. That
58
+ decision's third numbered point is "every renderer degrades to
59
+ something readable with no JavaScript, because a pane is rendered on
60
+ the server before anything runs." A title Chart.js paints is not
61
+ there until the chart runtime has loaded and `connect()` has run;
62
+ server-rendered text is there in the same response that draws the
63
+ rest of the pane.
64
+ - **It is not text.** Not selectable, not found by the browser's find
65
+ bar, not sized by the vitral theme's typography, not read by a
66
+ screen reader that has switched off image descriptions but still
67
+ reads a page's headings.
68
+
69
+ None of that is a knock on Chart.js. It is the same argument ADR 026
70
+ already settled for the chart itself: a renderer draws in HTML unless
71
+ it genuinely cannot, and a caption is not a case a canvas is needed for.
72
+
73
+ ### Where the title should live
74
+
75
+ A table pane already pairs a `<table>` with a `<caption>`. HTML has the
76
+ matching pair for an image with a caption: `<figure>` and
77
+ `<figcaption>`. Wrapping the existing canvas in one costs nothing Chart.js
78
+ cares about, since `new Chart(this.element, ...)` still receives the
79
+ canvas itself, unchanged.
80
+
81
+ One thing about that pairing had to be checked rather than assumed:
82
+ `<figcaption>` gives its text to the `<figure>` as a description, not
83
+ to an arbitrary element nested inside it. A screen reader is not
84
+ guaranteed to treat the figcaption as the canvas's own accessible name
85
+ just because it sits beside it. The canvas needs an explicit
86
+ `aria-labelledby` pointing at the figcaption's id; the implicit
87
+ figure/figcaption pairing is not enough on its own and this record
88
+ does not lean on it.
89
+
90
+ ### What was considered
91
+
92
+ **Chart.js's `plugins.title`**, #61's own suggestion. Rejected above.
93
+
94
+ **A bare sibling, `<div class="janela-chart-title">` before an
95
+ unwrapped canvas.** Considered and rejected only because HTML already
96
+ has the element built for exactly this pairing, and using it costs
97
+ nothing: a chart pane on its own page already reads as a captioned
98
+ figure to a browser's own outline, not just to Janela's CSS.
99
+
100
+ **Leaving `aria-label` on the canvas alongside the new visible text.**
101
+ Rejected: two independent strings for the same title can drift, and
102
+ `aria-labelledby` pointing at the one that is now visible cannot.
103
+
104
+ ## Decision
105
+
106
+ **A chart pane's title is a `<figcaption>` inside a `<figure>` that
107
+ wraps the canvas, and the canvas's accessible name is
108
+ `aria-labelledby` pointing at it.**
109
+
110
+ ```erb
111
+ <figure class="janela-pane">
112
+ <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
113
+ <canvas class="janela-chart"
114
+ data-controller="janela--chart"
115
+ ...
116
+ role="img" aria-labelledby="<%= title_id %>"></canvas>
117
+ </figure>
118
+ ```
119
+
120
+ **`janela-pane` moves from the canvas to the figure; `janela-chart`
121
+ stays on the canvas.** The figure is now the grid item and the thing a
122
+ theme styles as a pane, the same job `<table class="janela-pane">` and
123
+ `<p class="janela-pane janela-value">` already do for the other two
124
+ renderers. The canvas keeps `janela-chart` because that is what
125
+ `canvas.janela-chart { width: 100% !important; max-height: 20rem; }`
126
+ and the Stimulus controller both already target, and because #61's own
127
+ system test selects `canvas.janela-chart` directly; nothing that
128
+ selects the canvas by that class alone stops matching. What stops
129
+ matching is anything that selected `.janela-pane.janela-chart` as one
130
+ element, which is the breaking part of this change.
131
+
132
+ **`janela-chart-title` joins the pane primitives on the contract**
133
+ (ADR 036), the same group `janela-value-label` and a table's `caption`
134
+ selector are already in. Its base legibility rule (size, weight,
135
+ spacing) goes in `janela.css` next to the other two, not in vitral,
136
+ for the same reason theirs does: a pane has to be legible with no
137
+ theme installed.
138
+
139
+ **`.janela-own-headings` gets a third rule.** It already hides the
140
+ table's caption and the value's label visually while keeping them in
141
+ the accessibility tree; `figcaption.janela-chart-title` joins that
142
+ selector, and the comment above it that says a chart pane needs
143
+ nothing is corrected to say why it no longer does.
144
+
145
+ **Chart.js's dataset `label` and the disabled legend are unchanged.**
146
+ The visible title no longer depends on either, which is the point:
147
+ it is there whether or not the chart runtime ever finishes loading.
148
+
149
+ ## Consequences
150
+
151
+ - A chart pane finally shows its title the way the other two renderers
152
+ do, closing #61 and the half of #26 that was left standing on
153
+ purpose.
154
+ - Breaking change: `janela-pane` is no longer on the `<canvas>`, and
155
+ `aria-label` is replaced by `aria-labelledby`. Anything a host wrote
156
+ against either goes in `UPGRADING.md` (ADR 015), and the doctor's
157
+ list of things to check for an old release is worth a line.
158
+ - `docs/theming.md` gains `janela-chart-title` in the pane primitives
159
+ table (ADR 036), and `janela.css` gains its rule and its line in
160
+ `.janela-own-headings`.
161
+ - The system test in `test/system/chart_test.rb` asserts
162
+ `canvas.janela-chart[aria-label='...']` today; it moves to asserting
163
+ the figcaption's text and the `aria-labelledby` wiring, which is
164
+ build work, not a decision.
165
+ - Chart.js keeps drawing bar and line exactly as it does today; nothing
166
+ about `chart_controller.js`'s own options needs to change for this,
167
+ only the markup around it.
168
+ - What would change this decision: a chart type that cannot sit inside
169
+ a figure the way bar and line do, or a case where wrapping the canvas
170
+ measurably breaks Chart.js's own sizing against its parent. Neither
171
+ is expected, since the canvas already sits one level inside a turbo
172
+ frame today and gains only one more ordinary block-level ancestor,
173
+ but it is exactly the kind of assumption to re-check against the demo
174
+ before the build lands rather than after.
@@ -21,24 +21,25 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
21
21
  | Topic | ADRs |
22
22
  |-------|------|
23
23
  | **Vision, scope, forkability** | 001, 010, 012, 037 |
24
- | **Open-source & host-decoupling** | 001, 022, 036 |
25
- | **DSL & query layer** | 002, 006, 007, 020, 025 |
24
+ | **Open-source & host-decoupling** | 001, 022, 036, 041 |
25
+ | **DSL & query layer** | 002, 006, 007, 020, 025, 038 |
26
26
  | **Dependencies** | 002, 003, 004, 006, 017, 025 |
27
- | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035 |
27
+ | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040 |
28
28
  | **Performance & storage** | 007, 017, 025 |
29
- | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
30
- | **Layouts & views** | 011, 012, 016, 018, 020, 027 |
31
- | **CSS & styling** | 016, 018, 023, 026, 027, 036 |
32
- | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033 |
29
+ | **Ordering & formatting** | 007, 020, 038 |
30
+ | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040 |
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 |
33
34
  | **Naming rule** | 014, 023, 036 |
34
- | **JavaScript delivery & charts** | 004, 006, 026 |
35
+ | **JavaScript delivery & charts** | 004, 006, 026, 042 |
35
36
  | **Time dimensions** | 006, 025 |
36
- | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
37
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041 |
37
38
  | **Snapshots & publishing** | 009, 020, 028, 033, 034 |
38
39
  | **AI agents & guidance** | 010, 015, 021 |
39
40
  | **The doctor & checks** | 021, 025, 032, 033, 035 |
40
- | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037 |
41
- | **Accessibility & keyboard** | 024 |
41
+ | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042 |
42
+ | **Accessibility & keyboard** | 024, 042 |
42
43
  | **Security** | 003, 025, 028, 031, 032, 034, 035 |
43
44
  | **Testing** | 003 |
44
45
  | **Roadmap & planning** | 001, 037 |
@@ -84,7 +85,12 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
84
85
  | 035 | A Check Does What Janela Does, or It Says What It Saw | 2026-09-22 | Accepted |
85
86
  | 036 | Janela Publishes What a Theme May Target, and Vitral Is Only One | 2026-09-22 | Accepted |
86
87
  | 037 | 1.0 Means the Surface Stops Moving, Not That Janela Is Finished | 2026-09-23 | Accepted |
88
+ | 038 | A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage | 2026-09-24 | Proposed |
89
+ | 039 | A Pane Can Hold Words, and Only Code Writes Markup | 2026-09-24 | Accepted |
90
+ | 040 | A Host Can Fix a Frame's Filter, and No Click Removes It | 2026-09-24 | Accepted |
91
+ | 041 | A Host Finds Its Frame by Owner and Key | 2026-09-24 | Accepted |
92
+ | 042 | A Chart's Title Is a Figcaption, and the Canvas Points to It | 2026-09-28 | Accepted |
87
93
 
88
94
  ## Next number
89
95
 
90
- Next ADR: 038
96
+ Next ADR: 043