janela 0.2.1 → 0.3.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 +38 -1
- data/README.md +200 -20
- data/UPGRADING.md +136 -0
- data/app/assets/javascripts/janela/chart_controller.js +12 -5
- data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
- data/app/assets/stylesheets/janela.css +163 -0
- data/app/controllers/janela/application_controller.rb +18 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +149 -0
- data/app/models/janela/snapshot.rb +5 -5
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +4 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -46
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +13 -5
- data/config/importmap.rb +1 -1
- data/config/locales/en.yml +64 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/INDEX.md +22 -7
- data/docs/multi-tenancy.md +175 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +15 -2
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +17 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +54 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 005, ADR 008, ADR 009, ADR 010, ADR 011, ADR 012, ADR 013
|
|
5
|
+
Supersedes: part of ADR 012, part of ADR 013
|
|
6
|
+
Triggers:
|
|
7
|
+
- building frames or panes as records
|
|
8
|
+
- naming anything in the gem's public vocabulary
|
|
9
|
+
- adding a route, a layout or an index to the engine
|
|
10
|
+
- scoping or authorising a frame
|
|
11
|
+
- rendering a pane from a row rather than from URL parameters
|
|
12
|
+
Topics: frames, panes, naming, routes, layouts, authorisation, tenancy, sequencing
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# ADR 014: Corrections Before Frames Are Built
|
|
16
|
+
|
|
17
|
+
## Context
|
|
18
|
+
|
|
19
|
+
ADR 012 made frames and panes data and ADR 013 decided how a frame is
|
|
20
|
+
named and addressed. Both were accepted within an hour of each other
|
|
21
|
+
at the end of a long session. An independent review before any code
|
|
22
|
+
was written found three decisions that are wrong rather than
|
|
23
|
+
incomplete, and several claims that are overstated. This ADR corrects
|
|
24
|
+
them, records the naming rule that was implicit, and sequences the
|
|
25
|
+
build. It is the document to read before writing any of it.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
### A frame page renders its panes, not empty frames
|
|
30
|
+
|
|
31
|
+
ADR 013 has the engine serve an index and a show page for frames. ADR
|
|
32
|
+
011 has anything the engine serves render in Janela's own minimal
|
|
33
|
+
layout, which deliberately loads no assets. A frame page is a grid of
|
|
34
|
+
lazily loaded `turbo-frame` elements, so with no Turbo on the page
|
|
35
|
+
they never fetch and **the page renders blank**. The two ADRs conflict
|
|
36
|
+
and neither noticed.
|
|
37
|
+
|
|
38
|
+
A frame page therefore renders each pane's content inline, inside its
|
|
39
|
+
`turbo-frame`, on the first response. Cross-filtering still replaces
|
|
40
|
+
frames afterwards when Turbo is present, and without Turbo the page is
|
|
41
|
+
a correct static dashboard.
|
|
42
|
+
|
|
43
|
+
This is better than either ADR intended, for reasons beyond the
|
|
44
|
+
conflict: it removes a fan out of one request per pane on every load,
|
|
45
|
+
and it is what ADR 008 already required of a dashboard, that a shared
|
|
46
|
+
link be correct before any JavaScript runs. The host composed
|
|
47
|
+
dashboard should do the same, which makes `loading: :lazy` on a pane an
|
|
48
|
+
option rather than the default.
|
|
49
|
+
|
|
50
|
+
### A frame carries an opaque owner, and is always read through a scope
|
|
51
|
+
|
|
52
|
+
ADR 012 said authorisation is the host's Pundit policy on
|
|
53
|
+
`Janela::Frame`. A frame has no ownership column, so a multi tenant
|
|
54
|
+
host cannot write a `Scope` over it at all, and the shipped index
|
|
55
|
+
would list every tenant's frames with enumerable integer ids. One of
|
|
56
|
+
the two real hosts is multi tenant.
|
|
57
|
+
|
|
58
|
+
`Janela::Frame belongs_to :owner, polymorphic: true, optional: true`.
|
|
59
|
+
Janela never interprets the owner: it sets nothing, reads nothing from
|
|
60
|
+
it, and offers no roles. It exists so a host's policy `Scope` has
|
|
61
|
+
something to filter on. A single user host leaves it null.
|
|
62
|
+
|
|
63
|
+
Every controller lookup goes through the host's scope rather than
|
|
64
|
+
`find` on the class, so a frame belonging to another tenant is a 404
|
|
65
|
+
and ids stay unguessable in effect if not in form. The same correction
|
|
66
|
+
applies to the existing snapshot pane controller, which looks a
|
|
67
|
+
snapshot up unscoped today.
|
|
68
|
+
|
|
69
|
+
### A pane row is addressed as a row
|
|
70
|
+
|
|
71
|
+
ADR 012 assumed a persisted pane could be rendered through the
|
|
72
|
+
existing pane URL. It cannot, for two reasons found in the code. The
|
|
73
|
+
`turbo-frame` id is derived from the query, so two rows in one frame
|
|
74
|
+
with the same measure, dimension and renderer produce duplicate DOM
|
|
75
|
+
ids and Turbo replaces the wrong one. And a row's own title cannot
|
|
76
|
+
reach the response, because the controller builds everything from URL
|
|
77
|
+
parameters.
|
|
78
|
+
|
|
79
|
+
A pane row gets its own nested route, `frames/:frame_id/panes/:id`,
|
|
80
|
+
identified in the DOM as `janela_pane_<id>`, built from the row. The
|
|
81
|
+
ADR 005 pane grammar stays exactly as published and keeps its purpose:
|
|
82
|
+
an ad hoc pane, addressable and shareable without a row existing.
|
|
83
|
+
|
|
84
|
+
This also resolves a collision ADR 012 missed. The controller that
|
|
85
|
+
renders a query is already `PanesController`, and the forms surface
|
|
86
|
+
needs that name for CRUD. The query renderer keeps the ADR 005 URL and
|
|
87
|
+
is renamed with it.
|
|
88
|
+
|
|
89
|
+
### Frames sit at the mount root, and the path configuration is dropped
|
|
90
|
+
|
|
91
|
+
ADR 013 gave frames their own path segment, configurable, defaulting
|
|
92
|
+
to `dashboards`, and claimed this removed the collision with the pane
|
|
93
|
+
grammar entirely. That claim is false. `/dashboards/3` matches the
|
|
94
|
+
pane route as model `dashboards` and measure `3`, and worse, a host
|
|
95
|
+
model whose route key equals the chosen segment, `Report`, `Dashboard`
|
|
96
|
+
or `Insight`, has its panes shadowed by it. Those are precisely the
|
|
97
|
+
words the option exists to allow.
|
|
98
|
+
|
|
99
|
+
Frames live at the mount root with a numeric id constraint:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
/insights every frame
|
|
103
|
+
/insights/3 one frame
|
|
104
|
+
/insights/orders/revenue an ad hoc pane, grammar unchanged
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No model route key is all digits, so nothing collides, and the mount
|
|
108
|
+
path is already the host's noun by ADR 005. The configuration value
|
|
109
|
+
ADR 013 added is deleted before it ships. ADR 001 prefers this.
|
|
110
|
+
|
|
111
|
+
### The security claim, stated accurately
|
|
112
|
+
|
|
113
|
+
ADR 012 claimed a persisted pane adds no query surface because
|
|
114
|
+
dimensions are the Ransack allowlist. The registry and the fetch or
|
|
115
|
+
raise lookups do hold, and a row genuinely cannot invent a query,
|
|
116
|
+
reach an unexposed model or widen what is filterable. Two corrections
|
|
117
|
+
to how it was stated:
|
|
118
|
+
|
|
119
|
+
- Dimensions are the allowlist **only when the model has not already
|
|
120
|
+
declared its own**. Janela skips generating the allowlist if the
|
|
121
|
+
host defined `ransackable_attributes`, so in that case the surface
|
|
122
|
+
is the host's list, not Janela's. Both real hosts define their own.
|
|
123
|
+
- Filter parameters are still permitted wholesale, so any Ransack
|
|
124
|
+
predicate on an allowed attribute is reachable. That is issue #8 and
|
|
125
|
+
is unchanged by frames.
|
|
126
|
+
|
|
127
|
+
Rows are validated against the registry on save, not only at render:
|
|
128
|
+
model, measure, dimension, renderer, granularity and limit. Otherwise
|
|
129
|
+
deleting a dimension from a `janela` block silently breaks every row
|
|
130
|
+
naming it, and the failure appears to a reader as a missing pane. A
|
|
131
|
+
pane's title is the first analyst authored text the gem renders, and
|
|
132
|
+
is escaped like any other string, never marked safe.
|
|
133
|
+
|
|
134
|
+
### A pane whose model has no policy fails as one pane
|
|
135
|
+
|
|
136
|
+
A frame spanning several models may include one the viewer's host has
|
|
137
|
+
no policy for, which raises inside a single pane request. That pane
|
|
138
|
+
renders a refusal in its own frame with a 403 rather than taking out
|
|
139
|
+
the response.
|
|
140
|
+
|
|
141
|
+
### Naming: uncommon but conceivable, so a host is not pigeonholed
|
|
142
|
+
|
|
143
|
+
The words in the gem's vocabulary, Janela, Frame, Pane, are chosen to
|
|
144
|
+
be uncommon and still conceivable. A class called `Dashboard` would
|
|
145
|
+
push every host into calling the thing a dashboard; `Frame` leaves
|
|
146
|
+
them free to say insight, report or scorecard through i18n while the
|
|
147
|
+
gem keeps one internal vocabulary. This is the same principle as the
|
|
148
|
+
mount path belonging to the host (ADR 005) and the noun coming from a
|
|
149
|
+
locale file (ADR 013), and it is a rule rather than a preference: **the
|
|
150
|
+
gem's own words should not become a host's product language.**
|
|
151
|
+
|
|
152
|
+
The review's argument for `Dashboard`, that it removes glue, is
|
|
153
|
+
therefore declined, and the glue is realigned to `Frame` instead:
|
|
154
|
+
|
|
155
|
+
- `janela_dashboard do ... end` becomes `janela_frame do ... end`, and
|
|
156
|
+
gains a record form, `janela_frame @frame`. One helper, two ways to
|
|
157
|
+
supply the panes.
|
|
158
|
+
- The Stimulus controller `janela--dashboard` becomes `janela--frame`,
|
|
159
|
+
and its file follows. Hosts register it by name, so this is a
|
|
160
|
+
breaking change for the two installations and belongs in the
|
|
161
|
+
changelog.
|
|
162
|
+
- The helper module becomes `Janela::FramesHelper`.
|
|
163
|
+
- Today's runtime `Janela::Pane` becomes `Janela::Query`, freeing
|
|
164
|
+
`Pane` for the record, as ADR 012 decided.
|
|
165
|
+
|
|
166
|
+
### Sequence: four builds, not one
|
|
167
|
+
|
|
168
|
+
1. **Rename only.** `Pane` to `Query`, `janela_dashboard` to
|
|
169
|
+
`janela_frame`, the Stimulus controller, the helper module. No new
|
|
170
|
+
behaviour, no records, both hosts updated for the controller name.
|
|
171
|
+
2. **Records and rendering.** Migrations, `Frame` and `Pane`, the
|
|
172
|
+
opaque owner, row validations, the nested pane route, inline first
|
|
173
|
+
render, the grid and the stylesheet. A host renders a frame in its
|
|
174
|
+
own page and owns the authorisation.
|
|
175
|
+
3. **The engine's own pages.** Index and show at the mount root, only
|
|
176
|
+
once 1 and 2 are settled and scoping is proven in a real host.
|
|
177
|
+
4. **Forms.** The analyst's editing surface, as conventional Rails
|
|
178
|
+
CRUD on the nested resources.
|
|
179
|
+
|
|
180
|
+
The visual editor keeps its own future ADR, and ADR 012's commitment
|
|
181
|
+
to it is softened here to undecided: the ownership argument justifies
|
|
182
|
+
records and forms, and does not by itself justify a canvas.
|
|
183
|
+
|
|
184
|
+
### Documentation that is now wrong
|
|
185
|
+
|
|
186
|
+
The README still lists a report designer as out of scope and says
|
|
187
|
+
there is no drag and drop designer. ADR 009 states there is no
|
|
188
|
+
Dashboard model. ADR 010's skill instructs agents not to build a
|
|
189
|
+
report designer, and requires the skill to describe only the README's
|
|
190
|
+
API. All three must be corrected as part of build 2, not after it.
|
|
191
|
+
|
|
192
|
+
## Consequences
|
|
193
|
+
|
|
194
|
+
- Nothing in ADR 012's core is reversed: frames and panes are still
|
|
195
|
+
data, created at runtime, and the HTML still derives from the rows.
|
|
196
|
+
What changes is how they are addressed, scoped and first rendered.
|
|
197
|
+
- One configuration value is removed before shipping and none is
|
|
198
|
+
added, so the gem still has exactly one, `parent_controller`.
|
|
199
|
+
- Inline first render means a frame page does one query per pane on
|
|
200
|
+
the server. At the scale a dashboard renders that is cheaper than
|
|
201
|
+
the request per pane it replaces, but it makes the ordering and
|
|
202
|
+
limit decision of ADR 007 load bearing for page speed rather than
|
|
203
|
+
only for legibility.
|
|
204
|
+
- The polymorphic owner is the first column Janela adds that it does
|
|
205
|
+
not itself use. It is a hook and is documented as one.
|
|
206
|
+
- Build 1 is a breaking change for two known installations with no
|
|
207
|
+
user visible benefit, which is the right moment to take it: the only
|
|
208
|
+
installations are the author's.
|
|
209
|
+
- Uncommon words are collision free by construction, which is the
|
|
210
|
+
point of the rule: no host names a model `Frame` or `Pane`, so the
|
|
211
|
+
class, the CSS hook, the DOM id and the sentence "the pane is not
|
|
212
|
+
loading" are unambiguous in any application with no coordination.
|
|
213
|
+
The application this gem was extracted from already has a
|
|
214
|
+
`Dashboard` model, a `DashboardPolicy` and a `dashboards` table, so
|
|
215
|
+
the declined name would have collided in conversation there
|
|
216
|
+
permanently. Active Storage's `Blob` and Action Text's `RichText`
|
|
217
|
+
are the same choice; Blazer's `Dashboard` is the counter example.
|
|
218
|
+
- The cost is a permanent translation tax on documentation. Every
|
|
219
|
+
README example, error message and line of ADR 010's skill has to
|
|
220
|
+
decide whether to speak the gem's word or the reader's, and a gloss
|
|
221
|
+
like "a frame is a dashboard" never stops being necessary. That is
|
|
222
|
+
accepted as much smaller than a name that collides in every host.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 010, ADR 014
|
|
5
|
+
Triggers:
|
|
6
|
+
- making any change a host must act on
|
|
7
|
+
- cutting a release whose version's middle number changes
|
|
8
|
+
- adding a deprecation, an alias or a compatibility shim
|
|
9
|
+
- adding or changing what janela:doctor checks
|
|
10
|
+
- writing anything an agent is expected to follow
|
|
11
|
+
Topics: releases, upgrades, documentation, ai, dx, deprecation
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 015: How Breaking Change Is Communicated
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
Build 1 of ADR 014 renamed a helper, a Stimulus controller, a helper
|
|
19
|
+
module, two controllers and a class. Every host has to act on it, and
|
|
20
|
+
the only record is `CHANGELOG.md`, which a reader has to know to look
|
|
21
|
+
for. The failures a host meets first are uninformative:
|
|
22
|
+
`undefined method 'janela_dashboard'` and `uninitialized constant
|
|
23
|
+
Janela::Pane` say nothing about what replaced them.
|
|
24
|
+
|
|
25
|
+
The people and agents upgrading this gem are the same ones ADR 010 was
|
|
26
|
+
written for. That ADR decided guidance ships inside the gem so nobody
|
|
27
|
+
has to rediscover what the gem already knows. An upgrade path is
|
|
28
|
+
guidance, and none has shipped. This is the first case where the
|
|
29
|
+
principle has something concrete to do.
|
|
30
|
+
|
|
31
|
+
A changelog is also the wrong document for the job. It records what
|
|
32
|
+
changed, for a reader deciding whether to upgrade. It does not tell
|
|
33
|
+
someone mid-upgrade what to do, in order, with exact before and after.
|
|
34
|
+
Those are different readers and mixing them serves neither.
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
**`UPGRADING.md` ships inside the gem.** One section per version that
|
|
39
|
+
requires action, newest first, written as imperative steps with the
|
|
40
|
+
exact old and new text. Not prose: something a person or an agent can
|
|
41
|
+
work through and tick off. It is listed in the gemspec's files, so it
|
|
42
|
+
travels with an installed gem and is readable without network access.
|
|
43
|
+
|
|
44
|
+
The division of labour: `CHANGELOG.md` says what changed and why,
|
|
45
|
+
`UPGRADING.md` says what to do about it, `docs/decisions/` says why
|
|
46
|
+
the change was decided at all.
|
|
47
|
+
|
|
48
|
+
**A breaking release sets `post_install_message`.** One or two lines
|
|
49
|
+
naming the version and pointing at `UPGRADING.md`. Bundler prints it
|
|
50
|
+
when the gem is updated, which makes it the only mechanism that
|
|
51
|
+
reaches whoever, or whatever, ran the command at the moment they
|
|
52
|
+
needed it.
|
|
53
|
+
|
|
54
|
+
Used only for releases that require host action, and removed in the
|
|
55
|
+
release after. A post install message on every version is noise that
|
|
56
|
+
teaches people to skip it.
|
|
57
|
+
|
|
58
|
+
**`rails janela:doctor` reports what a host needs to fix.** One
|
|
59
|
+
command, a list of findings, exit status zero when clean. It checks
|
|
60
|
+
what experience says actually breaks a host:
|
|
61
|
+
|
|
62
|
+
- Stale identifiers from any previous version, for example
|
|
63
|
+
`janela--dashboard` or `janela_dashboard` in the host's markup and
|
|
64
|
+
JavaScript, or `Janela::Pane` in its Ruby.
|
|
65
|
+
- Whether Janela's Stimulus controllers are registered at all, which
|
|
66
|
+
is the failure that looks like nothing happening.
|
|
67
|
+
- A `through:` dimension whose associated model does not allowlist the
|
|
68
|
+
attribute, which Ransack refuses per class.
|
|
69
|
+
- Whether Janela's endpoints are reachable without authentication,
|
|
70
|
+
which depends entirely on what the host's `ApplicationController`
|
|
71
|
+
does.
|
|
72
|
+
- Whether the engine is mounted, and where.
|
|
73
|
+
|
|
74
|
+
This is the most agent shaped surface the gem can offer: one command
|
|
75
|
+
whose output is a list of actions rather than a paragraph to
|
|
76
|
+
interpret. It covers install as well as upgrade, so the traps ADR 010
|
|
77
|
+
listed become something a machine finds rather than something a person
|
|
78
|
+
remembers.
|
|
79
|
+
|
|
80
|
+
**No deprecation aliases and no compatibility shims.** A renamed
|
|
81
|
+
helper is not kept alive with a warning. The codebase's posture is
|
|
82
|
+
that a thing has one name, and a shim is a second name that lives
|
|
83
|
+
forever because nobody dares delete it. A loud failure plus a
|
|
84
|
+
findable, executable guide is better than a quiet alias, particularly
|
|
85
|
+
before 1.0 where the version number already says what to expect.
|
|
86
|
+
|
|
87
|
+
The exception, if one is ever wanted, is a constant that raises with a
|
|
88
|
+
message naming its replacement, because that is an error rather than a
|
|
89
|
+
second way to write working code. Not adopted now.
|
|
90
|
+
|
|
91
|
+
**ADR 010's skill will point at all three.** When the skill ships it
|
|
92
|
+
tells an agent to read `UPGRADING.md` before changing a version
|
|
93
|
+
constraint and to run `janela:doctor` after, which is the loop this
|
|
94
|
+
ADR exists to make possible.
|
|
95
|
+
|
|
96
|
+
## Consequences
|
|
97
|
+
|
|
98
|
+
- Three documents now have to stay in step on a breaking release:
|
|
99
|
+
changelog entry, upgrade section, and the doctor check that catches
|
|
100
|
+
the thing being renamed. The doctor is what keeps the other two
|
|
101
|
+
honest, because a check either finds the stale identifier or it does
|
|
102
|
+
not.
|
|
103
|
+
- `janela:doctor` reads the host's source, which is the first time the
|
|
104
|
+
gem inspects an application rather than serving it. It only reads,
|
|
105
|
+
reports, and changes nothing.
|
|
106
|
+
- A doctor check for authentication has to guess, since whether an
|
|
107
|
+
endpoint is public depends on the host's controller. It reports what
|
|
108
|
+
it observes and says plainly that it cannot be certain, rather than
|
|
109
|
+
asserting a system is safe.
|
|
110
|
+
- Refusing aliases means an upgrade cannot be gradual within one
|
|
111
|
+
version. That is the intended cost: the guide is short because there
|
|
112
|
+
is exactly one way the code can be.
|
|
113
|
+
- This ADR is itself the thing that makes 0.3.0 shippable to anyone
|
|
114
|
+
other than the author.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 012, ADR 014
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding or renaming a CSS class the engine renders
|
|
7
|
+
- adding a layout value a person can edit
|
|
8
|
+
- changing the spacing scale or the grid
|
|
9
|
+
- a host asking how to restyle Janela
|
|
10
|
+
Topics: css, layout, styling, public-api, security
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 016: The Styling Vocabulary
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 012 made a frame's layout data: how many columns, what gap, how
|
|
18
|
+
many columns a pane spans. ADR 014 confirmed the vocabulary is CSS
|
|
19
|
+
Grid's rather than a new glossary. Two things still had to be decided
|
|
20
|
+
before any of it is rendered.
|
|
21
|
+
|
|
22
|
+
The first is safety. These values are edited by an analyst, and a
|
|
23
|
+
length like `1rem` interpolated into a style attribute is injectable:
|
|
24
|
+
`1rem; position: fixed; top: 0` is valid CSS and escaping does not
|
|
25
|
+
help inside an attribute. The field is harmless today only because
|
|
26
|
+
nobody can type into it yet, which changes in build 4.
|
|
27
|
+
|
|
28
|
+
The second is that Janela ships no CSS at all, so a table pane renders
|
|
29
|
+
with no padding and a label runs into its number, reading as `false55`
|
|
30
|
+
(issue #15). A grid means nothing without CSS, so the stylesheet stops
|
|
31
|
+
being optional the moment frames exist.
|
|
32
|
+
|
|
33
|
+
Tailwind is the obvious model, and the part worth taking is not
|
|
34
|
+
utility classes but the reason they work: the scale is **finite and
|
|
35
|
+
indexed rather than measured**, so an inconsistent design is not
|
|
36
|
+
expressible.
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
**Layout values are small integers, and the gem supplies the units.**
|
|
41
|
+
`columns` 1 to 12, `gap` 0 to 8, `span` 1 to 12. An integer cannot
|
|
42
|
+
inject anything, so the field is safe by construction rather than by a
|
|
43
|
+
regular expression somebody has to keep trusting.
|
|
44
|
+
|
|
45
|
+
**An integer selects a class; it is never interpolated into a style
|
|
46
|
+
attribute.** The engine renders class names and the shipped stylesheet
|
|
47
|
+
defines them:
|
|
48
|
+
|
|
49
|
+
```css
|
|
50
|
+
.janela-frame { display: grid; }
|
|
51
|
+
.janela-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
52
|
+
.janela-gap-4 { gap: calc(var(--janela-space) * 4); }
|
|
53
|
+
.janela-span-2 { grid-column: span 2; }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Around thirty rules, enumerated for every value the validations
|
|
57
|
+
permit. Nothing an analyst supplies reaches CSS: the integer only
|
|
58
|
+
chooses a rule that was already written.
|
|
59
|
+
|
|
60
|
+
**One naming rule: `janela-{property}-{scale}`.** `janela-cols-3`,
|
|
61
|
+
`janela-gap-4`, `janela-span-2`. Learn it once and the rest is
|
|
62
|
+
predictable, which is doing more work in Tailwind's success than any
|
|
63
|
+
individual class.
|
|
64
|
+
|
|
65
|
+
**One base unit is the whole spacing theme.** `--janela-space`,
|
|
66
|
+
defaulting to `0.25rem`. A host sets it once and the entire scale
|
|
67
|
+
moves, without touching data or overriding rules.
|
|
68
|
+
|
|
69
|
+
**Responsive behaviour is in the stylesheet, not in the data.** The
|
|
70
|
+
grid collapses to a single column below a narrow breakpoint. An
|
|
71
|
+
analyst chooses a column count, not a set of breakpoints, and a
|
|
72
|
+
dashboard that is unreadable on a phone is not a choice worth
|
|
73
|
+
offering. Per breakpoint control, if ever needed, is a separate
|
|
74
|
+
decision.
|
|
75
|
+
|
|
76
|
+
**No arbitrary value escape hatch.** Tailwind offers `gap-[17px]`
|
|
77
|
+
because a developer occasionally has to defy the design system. Here
|
|
78
|
+
the editor is an analyst, the constraint is the point, and an escape
|
|
79
|
+
hatch reintroduces exactly the injection surface these integers close.
|
|
80
|
+
|
|
81
|
+
**The class names are public API.** A host restyles Janela by
|
|
82
|
+
overriding `.janela-pane`, `.janela-gap-4` and the rest, or by setting
|
|
83
|
+
`--janela-space`, so renaming one is a breaking change and belongs in
|
|
84
|
+
`UPGRADING.md` like any other (ADR 015). The stylesheet ships as
|
|
85
|
+
`app/assets/stylesheets/janela.css` and a host includes it, rather
|
|
86
|
+
than the engine injecting it into a layout it does not own.
|
|
87
|
+
|
|
88
|
+
## Consequences
|
|
89
|
+
|
|
90
|
+
- Issue #15 is answered: the gem ships CSS, so a pane is legible on
|
|
91
|
+
install, and a host overrides rather than writes from scratch.
|
|
92
|
+
- An analyst cannot express `1.375rem` of gap. Intended: the realistic
|
|
93
|
+
choices are none, small, medium and large, and a finite scale is why
|
|
94
|
+
a dashboard built by several people still looks like one thing.
|
|
95
|
+
- The enumerated rules must stay in step with the validation ranges.
|
|
96
|
+
A value that validates but has no class renders unstyled and
|
|
97
|
+
silently, so the test suite asserts the two agree.
|
|
98
|
+
- Janela now owns a stylesheet, which is a surface it has to maintain
|
|
99
|
+
and a thing hosts will want to argue with. Custom properties are
|
|
100
|
+
the pressure valve: retheme without forking.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 004, ADR 007, ADR 009
|
|
5
|
+
Triggers:
|
|
6
|
+
- proposing a data store, warehouse, cache or copy of a host's data
|
|
7
|
+
- a pane or a frame being measurably slow on a real host
|
|
8
|
+
- adding a database adapter or a second connection
|
|
9
|
+
- anything that would query outside ActiveRecord and therefore outside the host's scope
|
|
10
|
+
Topics: performance, columnar, storage, authorisation, scope, dependencies
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 017: Janela Owns No Data Store
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
Commercial BI tools mostly answer performance by taking a copy: extract
|
|
18
|
+
the host's data into a columnar store the vendor owns, and query that.
|
|
19
|
+
Columnar storage is genuinely the right shape for what Janela does, a
|
|
20
|
+
`SUM` or a `COUNT` grouped by one dimension over many rows, and the
|
|
21
|
+
motivation for keeping that store local rather than in someone else's
|
|
22
|
+
cloud is the same instinct that made this project worth starting.
|
|
23
|
+
|
|
24
|
+
The Postgres ecosystem has caught up in a way that matters here.
|
|
25
|
+
[`pg_duckdb` reached 1.0](https://motherduck.com/blog/pg-duckdb-release/)
|
|
26
|
+
with over a million downloads by its own account,
|
|
27
|
+
[`pg_ducklake` became production ready in January 2026](https://github.com/duckdb/pg_duckdb),
|
|
28
|
+
and Citus has had a columnar access method for years. Each of these
|
|
29
|
+
puts a vectorised columnar engine **inside** Postgres rather than
|
|
30
|
+
beside it, which is the distinction this ADR turns on. Read those
|
|
31
|
+
before reopening the question: they are the reason it is worth
|
|
32
|
+
reopening at all, and they are also the reason it does not need to be
|
|
33
|
+
reopened yet.
|
|
34
|
+
|
|
35
|
+
ADR 001 already declined a separate data warehouse and ETL. This ADR
|
|
36
|
+
records why that holds even now that a local columnar store is
|
|
37
|
+
practical, and what Janela does instead.
|
|
38
|
+
|
|
39
|
+
## Decision
|
|
40
|
+
|
|
41
|
+
**Janela never holds a copy of a host's data.** No warehouse, no
|
|
42
|
+
extract, no second connection, no adapter of its own.
|
|
43
|
+
|
|
44
|
+
The reason is not performance, it is authorisation. Everything safe
|
|
45
|
+
about this gem rests on `on: policy_scope(Order)`, an ActiveRecord
|
|
46
|
+
relation carrying the host's Pundit scope and whatever tenancy it
|
|
47
|
+
uses (ADR 002, ADR 004). A copy has no relation, no policy and no
|
|
48
|
+
tenant filter, so a copy means reimplementing row level security
|
|
49
|
+
against it. That is the subsystem ADR 001 refused, and getting it
|
|
50
|
+
subtly wrong shows one tenant's numbers to another, which is a far
|
|
51
|
+
worse failure than a slow pane.
|
|
52
|
+
|
|
53
|
+
**Janela stays fast by generating SQL a columnar engine can
|
|
54
|
+
accelerate, and by getting out of the way.** A host that needs
|
|
55
|
+
analytical speed converts the table it reports on, with
|
|
56
|
+
[`pg_duckdb`](https://github.com/duckdb/pg_duckdb), `pg_ducklake`,
|
|
57
|
+
Citus columnar or whatever its own database offers. The
|
|
58
|
+
table stays addressable by ActiveRecord, so Pundit, tenancy and every
|
|
59
|
+
part of Janela's query path keep working, and **Janela gets faster
|
|
60
|
+
with no change to the gem at all**. That is a page of documentation
|
|
61
|
+
rather than an architecture, and it is the right division: the host
|
|
62
|
+
owns its storage, the gem owns the question.
|
|
63
|
+
|
|
64
|
+
It follows that the gem's obligation is to emit plain, grouped,
|
|
65
|
+
aggregate SQL and not to outsmart the planner. ADR 007's ordering and
|
|
66
|
+
limit are the whole of the performance surface Janela owns.
|
|
67
|
+
|
|
68
|
+
**Snapshots are already the useful part of a warehouse.** ADR 009
|
|
69
|
+
persists frozen results with no extract, no staleness question and no
|
|
70
|
+
authorisation problem, because a snapshot was taken *under* a scope
|
|
71
|
+
and stores only what that scope returned. Anything that wants
|
|
72
|
+
precomputed aggregates should reach for a snapshot before it reaches
|
|
73
|
+
for a store.
|
|
74
|
+
|
|
75
|
+
**No measured problem exists.** The demo runs on 600 rows, the
|
|
76
|
+
commercial host's largest reported table on about 1,100, the personal
|
|
77
|
+
host's on four. Nothing is slow, and ADR 001 is explicit about not
|
|
78
|
+
building for hypothetical futures.
|
|
79
|
+
|
|
80
|
+
**What would change this.** A pane, measured on a real host, that is
|
|
81
|
+
too slow to use after its table has been made columnar and after
|
|
82
|
+
ADR 007's limit has been applied. At that point the question is
|
|
83
|
+
whether Janela should read a host-owned materialised view or
|
|
84
|
+
continuous aggregate, which is still the host's data under the host's
|
|
85
|
+
scope, and not whether Janela should own a store. That distinction is
|
|
86
|
+
the thing this ADR is really protecting.
|
|
87
|
+
|
|
88
|
+
## Consequences
|
|
89
|
+
|
|
90
|
+
- A host with genuinely large tables must do something about its own
|
|
91
|
+
storage, and the gem can only document the options rather than solve
|
|
92
|
+
it. That is a real limitation and it is the correct one: the
|
|
93
|
+
alternative is a copy whose access control Janela cannot honestly
|
|
94
|
+
guarantee.
|
|
95
|
+
- The README gains a short performance section naming the columnar
|
|
96
|
+
extensions and pointing at snapshots. It must be honest that the
|
|
97
|
+
gem has not been tested against a large columnar table, because it
|
|
98
|
+
has not.
|
|
99
|
+
- Refusing a second connection also refuses a whole category of
|
|
100
|
+
feature: querying anything that is not an ActiveRecord model in the
|
|
101
|
+
host. Reporting across two applications, or over a CSV, or over a
|
|
102
|
+
warehouse the business already has, is out of scope for the same
|
|
103
|
+
reason.
|
|
104
|
+
- [`activerecord-duckdb`](https://rubygems.org/gems/activerecord-duckdb)
|
|
105
|
+
is at 0.1.0 and describes itself as incomplete, so even the tempting
|
|
106
|
+
version of this would be built on something immature today. That is
|
|
107
|
+
a timing observation rather than a reason, and it will stop being
|
|
108
|
+
true, which is why it is recorded as an observation.
|
|
109
|
+
- This is the third time a scope question has been settled by asking
|
|
110
|
+
where authorisation lives (ADR 002's `on:`, ADR 012's persisted
|
|
111
|
+
panes, now this). It is worth naming as the project's actual test:
|
|
112
|
+
**if a feature cannot be expressed as a scoped ActiveRecord
|
|
113
|
+
relation, it is probably not Janela's to build.**
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 009, ADR 011, ADR 013, ADR 016
|
|
5
|
+
Triggers:
|
|
6
|
+
- rendering a pane where the chart runtime may be absent
|
|
7
|
+
- adding a renderer, or changing what a renderer means
|
|
8
|
+
- a pane rendering as something other than its row says
|
|
9
|
+
- the engine serving a page of its own
|
|
10
|
+
Topics: rendering, renderers, charts, progressive-enhancement, engines
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 018: A Table Is the Universal Renderer
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 013 promised a host could install the gem and navigate its own
|
|
18
|
+
dashboards the same day, which is why the engine serves an index and a
|
|
19
|
+
frame page of its own. ADR 011 established that those pages render in
|
|
20
|
+
the engine's minimal layout, which loads none of the host's assets
|
|
21
|
+
because the engine cannot know their names or bundler. ADR 014 then
|
|
22
|
+
had a frame render its panes inline so that the page is correct before
|
|
23
|
+
any JavaScript.
|
|
24
|
+
|
|
25
|
+
Correct, but not complete. A chart pane is a `canvas` the Stimulus
|
|
26
|
+
chart controller fills in, and the engine's own pages deliberately
|
|
27
|
+
load no Stimulus and no Chart.js. So a frame with two chart panes
|
|
28
|
+
served by the engine shows two empty holes the height of a chart. The
|
|
29
|
+
claim that those pages are a correct static dashboard was not true.
|
|
30
|
+
|
|
31
|
+
The engine could not fix this by loading a chart runtime: emitting a
|
|
32
|
+
working module graph needs the host's importmap or bundle, which is
|
|
33
|
+
exactly what ADR 011 said the engine cannot assume.
|
|
34
|
+
|
|
35
|
+
## Decision
|
|
36
|
+
|
|
37
|
+
**Where no chart runtime exists, a chart pane renders as a table.**
|
|
38
|
+
|
|
39
|
+
The engine's own frame page passes `charts: false` to `janela_frame`,
|
|
40
|
+
and a pane whose renderer is `bar` or `line` renders its table instead.
|
|
41
|
+
A host's own page renders charts normally, because a host that
|
|
42
|
+
registered the Stimulus controllers has the runtime.
|
|
43
|
+
|
|
44
|
+
This is not JavaScript detection and it is not a fallback that guesses.
|
|
45
|
+
The engine's pages deterministically have no chart renderer, so they
|
|
46
|
+
deterministically use the one that needs nothing.
|
|
47
|
+
|
|
48
|
+
**A renderer is a viewing choice, not part of the pane.** ADR 009
|
|
49
|
+
already decided this when it made a snapshot store results rather than
|
|
50
|
+
markup, and this follows from it: the row records that a pane is best
|
|
51
|
+
seen as a bar chart, and a surface that cannot draw one shows the same
|
|
52
|
+
numbers another way. Nothing about the data changes.
|
|
53
|
+
|
|
54
|
+
**A table is the renderer that always works**, needing no JavaScript,
|
|
55
|
+
no canvas and no measurement, which is why it is the one to fall back
|
|
56
|
+
to rather than an error or an empty space.
|
|
57
|
+
|
|
58
|
+
## Consequences
|
|
59
|
+
|
|
60
|
+
- The engine's own pages are now what ADR 013 promised: a host mounts
|
|
61
|
+
the engine and reads a real dashboard the same day, with no
|
|
62
|
+
JavaScript wiring at all.
|
|
63
|
+
- The same frame can look different in two places: a bar chart on a
|
|
64
|
+
host's page and a table on the engine's. That is the cost, it will
|
|
65
|
+
surprise someone, and the README says so plainly rather than leaving
|
|
66
|
+
them to discover it.
|
|
67
|
+
- Cross-filtering is still absent from the engine's pages, because
|
|
68
|
+
that genuinely needs Stimulus. They are for reading, and a host that
|
|
69
|
+
wants interaction renders the frame in its own page, which is one
|
|
70
|
+
helper call.
|
|
71
|
+
- `charts:` is a rendering option rather than a stored column, so no
|
|
72
|
+
migration and nothing for an analyst to get wrong.
|
|
73
|
+
- If the engine ever gains a way to know a host's asset setup, this
|
|
74
|
+
decision is worth revisiting, because the better answer is a chart
|
|
75
|
+
that draws everywhere.
|