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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +33 -2
- data/UPGRADING.md +48 -0
- data/app/assets/javascripts/janela/frame_controller.js +15 -0
- data/app/assets/stylesheets/janela.css +16 -8
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/controllers/janela/panes_controller.rb +22 -7
- data/app/controllers/janela/queries_controller.rb +2 -1
- data/app/helpers/janela/frames_helper.rb +26 -7
- data/app/models/janela/frame.rb +20 -0
- data/app/models/janela/pane.rb +67 -4
- data/app/models/janela/query.rb +7 -2
- data/app/views/janela/frames/_content.html.erb +26 -0
- data/app/views/janela/frames/_frame.html.erb +9 -1
- data/app/views/janela/frames/_pane.html.erb +2 -2
- data/app/views/janela/panes/_content_form.html.erb +33 -0
- data/app/views/janela/panes/_row.html.erb +1 -1
- data/app/views/janela/panes/edit.html.erb +5 -1
- data/app/views/janela/panes/new.html.erb +6 -1
- data/app/views/janela/queries/_query.html.erb +15 -11
- data/config/locales/en.yml +5 -0
- data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
- data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
- data/docs/composing.md +269 -0
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
- data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
- data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
- data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
- data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
- data/docs/decisions/INDEX.md +18 -12
- data/docs/multi-tenancy.md +22 -0
- data/docs/naming.md +7 -0
- data/docs/roadmap.md +129 -8
- data/docs/theming.md +17 -15
- data/lib/janela/definition.rb +8 -0
- data/lib/janela/version.rb +1 -1
- 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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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
|
-
| **
|
|
30
|
-
| **
|
|
31
|
-
| **
|
|
32
|
-
| **
|
|
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:
|
|
96
|
+
Next ADR: 043
|