janela 0.8.0 → 0.10.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +41 -2
  4. data/UPGRADING.md +75 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +29 -3
  6. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  7. data/app/assets/stylesheets/janela.css +16 -8
  8. data/app/controllers/janela/application_controller.rb +7 -0
  9. data/app/controllers/janela/panes_controller.rb +22 -7
  10. data/app/controllers/janela/queries_controller.rb +2 -1
  11. data/app/helpers/janela/frames_helper.rb +26 -7
  12. data/app/models/janela/frame.rb +49 -0
  13. data/app/models/janela/pane.rb +67 -4
  14. data/app/models/janela/query.rb +10 -2
  15. data/app/views/janela/frames/_content.html.erb +26 -0
  16. data/app/views/janela/frames/_frame.html.erb +9 -1
  17. data/app/views/janela/frames/_pane.html.erb +2 -2
  18. data/app/views/janela/panes/_content_form.html.erb +33 -0
  19. data/app/views/janela/panes/_row.html.erb +1 -1
  20. data/app/views/janela/panes/edit.html.erb +5 -1
  21. data/app/views/janela/panes/new.html.erb +6 -1
  22. data/app/views/janela/queries/_query.html.erb +15 -11
  23. data/config/locales/en.yml +5 -0
  24. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  25. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  26. data/db/migrate/20260928000001_add_default_filter_to_janela_frames.rb +10 -0
  27. data/docs/composing.md +288 -0
  28. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  29. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  30. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  31. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  32. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  33. data/docs/decisions/043-a-frames-default-filter-names-the-model-it-narrows.md +163 -0
  34. data/docs/decisions/INDEX.md +19 -12
  35. data/docs/multi-tenancy.md +22 -0
  36. data/docs/naming.md +7 -0
  37. data/docs/roadmap.md +129 -8
  38. data/docs/theming.md +18 -16
  39. data/lib/janela/definition.rb +8 -0
  40. data/lib/janela/version.rb +1 -1
  41. metadata +20 -18
@@ -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.
@@ -0,0 +1,163 @@
1
+ ---
2
+ Date: 2026-09-28
3
+ Status: Accepted
4
+ Related: ADR 012, ADR 014, ADR 020, ADR 025, ADR 040
5
+ Triggers:
6
+ - a frame that should always exclude some rows, regardless of who renders it
7
+ - adding a column to Janela::Frame or Janela::Pane
8
+ - a host copying the same where: condition into every view that renders one frame
9
+ - validating a stored Ransack condition at save time rather than at render
10
+ Topics: frames, filters, cross-filtering, persistence, ransack
11
+ ---
12
+
13
+ # ADR 043: A Frame's Default Filter Names the Model It Narrows
14
+
15
+ ## Context
16
+
17
+ #63, found while dogfooding: ADR 040 gives a host a way to narrow a
18
+ frame to the current record, `where: { project_id_eq: @project.id }`,
19
+ code, per request, correctly not data because the value is inherently
20
+ per-record. It does not give a frame a way to say something that is
21
+ true on every render, forever: "this queue never counts an archived
22
+ row." That is not per-record narrowing, it is a permanent property of
23
+ what the dashboard means, and today it can only be expressed by typing
24
+ the same Ransack condition into every view that renders the frame.
25
+
26
+ ADR 012's own argument is that composition moved out of ERB because
27
+ the analyst, not the developer, owns a dashboard, and a decision typed
28
+ into a view is "a file held by the wrong owner." A permanent filter is
29
+ exactly that file, with nowhere on the data side to move to: `Frame`
30
+ and `Pane` have no column that could hold one.
31
+
32
+ ### What breaks the obvious shape
33
+
34
+ The obvious answer, a `default_where` column on `Frame` applied to
35
+ every pane in it, runs into a fact ADR 020 already used to reject the
36
+ frame as the layer for a different per-pane setting: a frame can hold
37
+ panes from more than one model. Measured against the demo, reusing the
38
+ existing bound check rather than assuming it:
39
+
40
+ ```ruby
41
+ Order.janela.narrow(Order.all, "expedited_eq" => "false")
42
+ # => Janela::BadRequest: Order does not allow filtering on expedited_eq.
43
+ # Declare a janela dimension, or add it to ransackable_attributes.
44
+ ```
45
+
46
+ `expedited` is a real column on `Order`, not a declared dimension. A
47
+ condition applied uniformly to every pane in a frame raises for any
48
+ pane whose model, or whose declared dimension set, does not carry the
49
+ attribute the condition names. ADR 020's own case was a format string
50
+ being "right for the currency and wrong for the count sitting next to
51
+ it"; a stored Ransack condition is at least as specific to one model.
52
+
53
+ ### What was considered for where the mismatch goes
54
+
55
+ **Raise for every pane whose model does not match.** Rejected: the
56
+ frame breaks the moment an analyst adds a pane over a second model, or
57
+ the moment a declared dimension is removed from the first, and the
58
+ failure is frame-wide rather than local to the pane that changed.
59
+
60
+ **Skip the condition quietly for a pane it does not apply to.** Rejected
61
+ outright. ADR 024, ADR 032 and ADR 034 all refuse the same shape of
62
+ quiet: a filter that means one thing for one pane and nothing for
63
+ another, on the same frame, with nothing on the page saying so.
64
+
65
+ **Restrict a frame with a default to panes over one model.** Considered
66
+ and rejected as an unnecessary restriction on `Pane`, which already
67
+ allows any model with a `janela` block. The mismatch is the default's
68
+ problem to solve, not a new constraint on what a frame may hold.
69
+
70
+ ## Decision
71
+
72
+ **A frame's default filter names the one model it narrows, and only a
73
+ pane over that model takes it.** `Janela::Frame` gains two columns,
74
+ `default_model` and `default_where`, both nullable and present or
75
+ absent together:
76
+
77
+ ```ruby
78
+ frame.update!(default_model: "orders", default_where: { "status_not_eq" => "archived" })
79
+ ```
80
+
81
+ **`default_model` is validated the same way `Pane#model` already is**:
82
+ blank, or a route key naming a model with a `janela` block, checked
83
+ through `Janela.definition!` and rescued the same way
84
+ `declared_by_a_janela_block` does. It does not have to match any pane
85
+ the frame currently holds. A frame can carry a default before its first
86
+ matching pane exists, and a pane over a different model is simply never
87
+ narrowed by it, no error, because the condition was never claimed to be
88
+ about it.
89
+
90
+ **`default_where` is validated against that one model's `Definition` at
91
+ save time**, not only discovered wrong at render. `Definition#narrow`
92
+ already runs `Ransack#ransack` and the three checks ADR 025 built
93
+ (dropped filters, disallowed predicates, oversized value lists); a new
94
+ validation calls it against `default_model`'s definition and turns a
95
+ raised `Janela::BadRequest` into a validation error on `default_where`,
96
+ the same sentence a request would have raised, read at the point an
97
+ analyst can still fix it. This is new ground: nothing today validates a
98
+ persisted, arbitrary Ransack condition at save time, only `fixed` and
99
+ the reader's `q[...]`, which are validated fresh on every render because
100
+ neither is ever stored.
101
+
102
+ **It composes as a third layer, ahead of the two ADR 040 already
103
+ built.** `Pane#query` asks its frame for the default that applies to its
104
+ own model and passes it to `Query`, which narrows with it before `fixed`
105
+ narrows again, before the reader's own filters:
106
+
107
+ ```ruby
108
+ # Janela::Frame
109
+ def default_for(model)
110
+ default_model == model.model_name.route_key ? default_where.to_h : {}
111
+ end
112
+
113
+ # Janela::Query#result
114
+ on = definition.narrow(on || model.all, default) if default.present? # frame, permanent
115
+ on = definition.narrow(on, fixed) if fixed.present? # host, per-record (ADR 040)
116
+ definition.query(measure, by: dimension, where: applicable_filters, on: on, ...) # reader
117
+ ```
118
+
119
+ Applied even on a pane's own dimension, the same as `fixed`: the point
120
+ of "this queue never counts an archived row" is that no pane on it ever
121
+ shows archived as one of its own bars either. Nothing the reader does
122
+ in `q[...]` can remove it, for the same reason nothing the reader does
123
+ can remove a host's fixed filter.
124
+
125
+ **Only a stored frame can have one.** A hand composed `janela_frame do
126
+ ... end` block has no `Frame` row for a default to live on; a host
127
+ composing a page in ERB already writes its own permanent conditions in
128
+ its own code, in one place, which is what ADR 012 left unchanged for
129
+ that path. This is additive to the data-driven half only.
130
+
131
+ **Vocabulary, so the three layers stay distinct.** *Default* is the
132
+ frame's own, permanent, data. *Fixed* stays ADR 040's word for the
133
+ host's own, per-record, code. *Filters*, or `q[...]`, stays the
134
+ reader's. Three words, three owners, and none of them is allowed to
135
+ mean another.
136
+
137
+ ## Consequences
138
+
139
+ - An analyst declares "this queue never counts an archived row" once,
140
+ as a row, and it holds across every page that ever renders the frame,
141
+ including one that does not exist yet.
142
+ - **A stale default is a frame-wide failure, not a pane-wide one.**
143
+ If `default_model`'s `janela` block later drops the dimension
144
+ `default_where` names, every pane over that model in the frame raises
145
+ at render, the same "missing pane" treatment `declared_by_a_janela_block`
146
+ already gives a stale measure or dimension, just wider: one condition
147
+ now speaks for every pane that shares its model rather than for one
148
+ row. Worth a line in the engine's own edit form once built.
149
+ - `janela_frames` gains `default_model` and `default_where`, both
150
+ nullable. Existing frames are unaffected either way; needs a migration
151
+ and an `UPGRADING.md` entry (ADR 015).
152
+ - **Not decided here: a frame with panes over two models, each wanting
153
+ its own permanent default.** One `(model, where)` pair per frame is
154
+ what is built. A second model needing one of its own is the trigger to
155
+ revisit this as a has-many rather than a second pair of columns, the
156
+ same way ADR 040 left a signed filter for the day a host's need for
157
+ one is real rather than guessed at.
158
+ - The engine's own frame form needs a way to set both columns together,
159
+ and to clear both together; not built here.
160
+ - What would change this decision: a host needing a permanent filter
161
+ that is not one model's own condition, such as one spanning an
162
+ association two different pane models both reach through. Nothing
163
+ proposes that today.
@@ -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, 043 |
31
+ | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039 |
32
+ | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042 |
33
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043 |
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,13 @@ 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 |
93
+ | 043 | A Frame's Default Filter Names the Model It Narrows | 2026-09-28 | Accepted |
87
94
 
88
95
  ## Next number
89
96
 
90
- Next ADR: 038
97
+ Next ADR: 044
@@ -149,6 +149,28 @@ save failed silently. `bin/rails janela:doctor` reports this for you:
149
149
  it asks your policy for a scope over frames and looks for an owner in
150
150
  what comes back.
151
151
 
152
+ ### A frame of your own for a page
153
+
154
+ Your code finds a frame it keeps for one of its pages by owner and key
155
+ (ADR 041):
156
+
157
+ ```ruby
158
+ Janela::Frame.for(Current.account, :overview)
159
+ Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
160
+ ```
161
+
162
+ When the owner is the tenant, nothing changes in your policy. When it is
163
+ one of your own records, such as the queue above, the frame belongs to
164
+ the tenant through that record, and your scope has to say so or the
165
+ frame is hidden from the people it was made for:
166
+
167
+ ```ruby
168
+ def resolve
169
+ scope.where(owner: Current.account)
170
+ .or(scope.where(owner_type: "Queue", owner_id: Current.account.queues.select(:id)))
171
+ end
172
+ ```
173
+
152
174
  ## Snapshots
153
175
 
154
176
  A snapshot carries the same polymorphic owner a frame does, so your
data/docs/naming.md CHANGED
@@ -44,6 +44,13 @@ from the thing itself rather than from a list of synonyms for "chart".
44
44
  | **Query** | The runtime object that calculates one pane | Not a window word, on purpose. It is an implementation detail rather than something a person arranges, so it gets the plain name for what it does. |
45
45
  | **Grid** | How a frame is divided: `columns`, `gap`, and each pane's `span` | A window is divided into panes, and the grid is the division. Small integers that choose a class the stylesheet already defines, so nothing an analyst types reaches CSS (ADR 016). |
46
46
  | **Vitral** | The optional stained glass theme | A stained glass window, in the same language. See ADR 023. |
47
+ | **Vista** | The roadmap | The view through the window. What you can see from here is also what is ahead: *outlook* and *prospect* carry the same double meaning in English, and this one carries it in Portuguese. |
48
+ | **Horizonte** | The roadmap's far half: wanted, not blocking a release | The horizon is the edge of what you can see. Work still in sight, as against work that is not in the picture at all. |
49
+
50
+ The last two are a step away from anatomy. A window's parts name the
51
+ things a host arranges; what you see through it names the things the
52
+ project has not built yet. Both halves come from the same object, which
53
+ is the point of picking an object rather than a list of synonyms.
47
54
 
48
55
  The anatomy was argued over before it was settled. *Sash* was
49
56
  considered for the dashboard and rejected: a sash is one layer inside a
data/docs/roadmap.md CHANGED
@@ -2,7 +2,125 @@
2
2
  Topics: roadmap, releases, scope, planning
3
3
  ---
4
4
 
5
- # Where Janela Is Going
5
+ # Vista
6
+
7
+ <p style="text-align: center;">Vista shows where Janela is heading.</p>
8
+
9
+ <svg viewBox="0 0 680 360" width="100%" role="img" aria-labelledby="vista-title vista-desc" class="vista-art" style="display: block; margin: 1.75rem 0; border-radius: 10px;">
10
+ <title id="vista-title">Vista</title>
11
+ <desc id="vista-desc">Sea and sky with a horizon across them. Eight lights burn on the near water, one for each issue in 1.0, and three sit far off at the horizon for the work still in sight past it. A low sun rises and sets on the horizon as the pointer moves up and down, and never climbs higher: the sky above is empty, because what is not coming is not in view.</desc>
12
+
13
+ <style>
14
+ .vista-art .v-far { transform: translate3d(calc(var(--vitral-shift-x, 0) * 13px), calc(var(--vitral-shift-y, 0) * 7px), 0); transition: transform .45s cubic-bezier(.2,.7,.3,1); }
15
+ .vista-art .v-mid { transform: translate3d(calc(var(--vitral-shift-x, 0) * 6px), calc(var(--vitral-shift-y, 0) * 3px), 0); transition: transform .45s cubic-bezier(.2,.7,.3,1); }
16
+ .vista-art .v-near { transform: translate3d(calc(var(--vitral-shift-x, 0) * -8px), calc(var(--vitral-shift-y, 0) * -4px), 0); transition: transform .45s cubic-bezier(.2,.7,.3,1); }
17
+ .vista-art .v-glow-a { opacity: calc(.6 - var(--vitral-shift-x, 0) * 1.2); transform: translate3d(calc(var(--vitral-shift-x, 0) * -40px), calc(var(--vitral-shift-y, 0) * 16px), 0); transition: transform .9s cubic-bezier(.2,.7,.3,1), opacity .9s ease; }
18
+ .vista-art .v-glow-b { opacity: calc(.6 + var(--vitral-shift-x, 0) * 1.2); transform: translate3d(calc(var(--vitral-shift-x, 0) * 44px), calc(var(--vitral-shift-y, 0) * -14px), 0); transition: transform .9s cubic-bezier(.2,.7,.3,1), opacity .9s ease; }
19
+ .vista-art .v-sun { transform: translate3d(calc(var(--vitral-shift-x, 0) * 10px), calc(var(--vitral-shift-y, 0) * 84px), 0); transition: transform 1.1s cubic-bezier(.2,.7,.3,1); }
20
+ .vista-art .v-glint { opacity: calc(.45 - var(--vitral-shift-y, 0) * .9); transform: translate3d(calc(var(--vitral-shift-x, 0) * 10px), 0, 0); transition: opacity 1.1s ease, transform 1.1s cubic-bezier(.2,.7,.3,1); }
21
+ @media (prefers-reduced-motion: reduce) {
22
+ .vista-art .v-sun, .vista-art .v-glint { transform: none; opacity: .45; }
23
+ .vista-art .v-far, .vista-art .v-mid, .vista-art .v-near, .vista-art .v-glow-a, .vista-art .v-glow-b { transform: none; opacity: .6; }
24
+ }
25
+ </style>
26
+
27
+ <defs>
28
+ <clipPath id="vFrame"><rect x="0" y="0" width="680" height="360" rx="10"/></clipPath>
29
+ <clipPath id="vAbove"><rect x="0" y="0" width="680" height="196"/></clipPath>
30
+ <radialGradient id="vSun">
31
+ <stop offset="0" stop-color="#FFF4D6" stop-opacity="0.95"/>
32
+ <stop offset="0.7" stop-color="#FFD58A" stop-opacity="0.9"/>
33
+ <stop offset="1" stop-color="#F7A96B" stop-opacity="0.75"/>
34
+ </radialGradient>
35
+ <radialGradient id="vSunHalo">
36
+ <stop offset="0" stop-color="#FFD98F" stop-opacity="0.5"/>
37
+ <stop offset="1" stop-color="#F7A96B" stop-opacity="0"/>
38
+ </radialGradient>
39
+ <linearGradient id="vSky" x1="0" y1="0" x2="0.22" y2="1">
40
+ <stop offset="0" stop-color="#5B49C9" stop-opacity="0.54"/>
41
+ <stop offset="0.38" stop-color="#8A6EE4" stop-opacity="0.34"/>
42
+ <stop offset="0.7" stop-color="#E06082" stop-opacity="0.29"/>
43
+ <stop offset="1" stop-color="#F0B046" stop-opacity="0.46"/>
44
+ </linearGradient>
45
+ <linearGradient id="vSea" x1="0" y1="0" x2="0.08" y2="1">
46
+ <stop offset="0" stop-color="#F0B046" stop-opacity="0.34"/>
47
+ <stop offset="0.22" stop-color="#1CA89E" stop-opacity="0.38"/>
48
+ <stop offset="1" stop-color="#132F5C" stop-opacity="0.78"/>
49
+ </linearGradient>
50
+ <radialGradient id="vLamp">
51
+ <stop offset="0" stop-color="#FFE9B8" stop-opacity="0.78"/>
52
+ <stop offset="1" stop-color="#FFD98F" stop-opacity="0"/>
53
+ </radialGradient>
54
+ <radialGradient id="vP1"><stop offset="0" stop-color="#367AEB" stop-opacity="0.6"/><stop offset="1" stop-color="#367AEB" stop-opacity="0"/></radialGradient>
55
+ <radialGradient id="vP2"><stop offset="0" stop-color="#1CA89E" stop-opacity="0.30"/><stop offset="1" stop-color="#1CA89E" stop-opacity="0"/></radialGradient>
56
+ <radialGradient id="vP3"><stop offset="0" stop-color="#E06082" stop-opacity="0.5"/><stop offset="1" stop-color="#E06082" stop-opacity="0"/></radialGradient>
57
+ </defs>
58
+
59
+ <g clip-path="url(#vFrame)">
60
+ <g class="v-far">
61
+ <rect x="-30" y="-30" width="740" height="256" fill="url(#vSky)"/>
62
+ <ellipse class="v-glow-a" cx="150" cy="104" rx="230" ry="132" fill="url(#vP1)"/>
63
+ <ellipse class="v-glow-b" cx="520" cy="140" rx="250" ry="100" fill="url(#vP3)"/>
64
+ </g>
65
+
66
+ <g clip-path="url(#vAbove)">
67
+ <g class="v-sun">
68
+ <circle cx="432" cy="192" r="96" fill="url(#vSunHalo)"/>
69
+ <circle cx="432" cy="192" r="24" fill="url(#vSun)"/>
70
+ </g>
71
+ </g>
72
+ <g class="v-mid">
73
+ <rect x="-30" y="196" width="740" height="204" fill="url(#vSea)"/>
74
+ <ellipse cx="580" cy="288" rx="220" ry="124" fill="url(#vP2)"/>
75
+ <path d="M6 200.0 h18 M46 200.0 h18 M80 200.0 h11 M145 200.0 h9 M173 200.0 h8 M204 200.0 h22 M235 200.0 h7 M266 200.0 h18 M305 200.0 h19 M339 200.0 h23 M372 200.0 h30 M424 200.0 h24 M490 200.0 h16 M519 200.0 h30 M555 200.0 h12 M581 200.0 h30 M618 200.0 h22 M650 200.0 h9" stroke="#FFF3DA" stroke-opacity="0.28" stroke-width="1" stroke-linecap="round"/>
76
+ <path d="M74 204.2 h12 M100 204.2 h12 M195 204.2 h28 M245 204.2 h13 M384 204.2 h10 M404 204.2 h23 M441 204.2 h31 M527 204.2 h30 M568 204.2 h12 M604 204.2 h12 M639 204.2 h23 M669 204.2 h8" stroke="#FFF3DA" stroke-opacity="0.28" stroke-width="1" stroke-linecap="round"/>
77
+ <path d="M127 209.4 h23 M169 209.4 h16 M278 209.4 h32 M329 209.4 h27 M398 209.4 h27 M431 209.4 h25 M477 209.4 h17 M501 209.4 h23 M548 209.4 h28 M598 209.4 h33 M650 209.4 h32" stroke="#FFF3DA" stroke-opacity="0.27" stroke-width="1" stroke-linecap="round"/>
78
+ <path d="M6 215.8 h32 M56 215.8 h27 M148 215.8 h38 M208 215.8 h20 M247 215.8 h22 M370 215.8 h23 M487 215.8 h35 M538 215.8 h35 M593 215.8 h15 M631 215.8 h36" stroke="#FFF3DA" stroke-opacity="0.26" stroke-width="1" stroke-linecap="round"/>
79
+ <path d="M6 223.7 h28 M44 223.7 h26 M96 223.7 h32 M141 223.7 h15 M169 223.7 h17 M207 223.7 h26 M258 223.7 h41 M308 223.7 h11 M326 223.7 h32 M492 223.7 h30 M543 223.7 h31" stroke="#FFF3DA" stroke-opacity="0.25" stroke-width="1" stroke-linecap="round"/>
80
+ <path d="M56 233.5 h23 M99 233.5 h33 M149 233.5 h39 M197 233.5 h44 M252 233.5 h27 M308 233.5 h24 M361 233.5 h39 M477 233.5 h40 M542 233.5 h18 M591 233.5 h46 M664 233.5 h22" stroke="#FFF3DA" stroke-opacity="0.24" stroke-width="1" stroke-linecap="round"/>
81
+ <path d="M52 245.5 h34 M107 245.5 h13 M246 245.5 h33 M309 245.5 h40 M494 245.5 h38 M562 245.5 h35 M615 245.5 h46" stroke="#FFF3DA" stroke-opacity="0.22" stroke-width="1" stroke-linecap="round"/>
82
+ <path d="M6 260.4 h52 M147 260.4 h37 M193 260.4 h58 M271 260.4 h50 M414 260.4 h33 M482 260.4 h26 M521 260.4 h33 M589 260.4 h35 M639 260.4 h42" stroke="#FFF3DA" stroke-opacity="0.20" stroke-width="1" stroke-linecap="round"/>
83
+ <path d="M79 278.8 h28 M126 278.8 h34 M310 278.8 h39 M432 278.8 h49 M507 278.8 h60 M605 278.8 h28 M669 278.8 h63" stroke="#FFF3DA" stroke-opacity="0.17" stroke-width="1" stroke-linecap="round"/>
84
+ <path d="M296 301.6 h69 M404 301.6 h26 M521 301.6 h73 M610 301.6 h49" stroke="#FFF3DA" stroke-opacity="0.14" stroke-width="1" stroke-linecap="round"/>
85
+ <path d="M156 329.6 h58 M314 329.6 h91 M422 329.6 h26 M479 329.6 h75 M587 329.6 h58" stroke="#FFF3DA" stroke-opacity="0.09" stroke-width="1" stroke-linecap="round"/>
86
+ <circle cx="186" cy="202" r="8" fill="url(#vLamp)" opacity="0.2"/>
87
+ <circle cx="186" cy="202" r="1.7" fill="#FFF3DC" opacity="0.55"/>
88
+ <circle cx="322" cy="201" r="8" fill="url(#vLamp)" opacity="0.2"/>
89
+ <circle cx="322" cy="201" r="1.5" fill="#FFF3DC" opacity="0.55"/>
90
+ <circle cx="540" cy="202.5" r="8" fill="url(#vLamp)" opacity="0.2"/>
91
+ <circle cx="540" cy="202.5" r="1.6" fill="#FFF3DC" opacity="0.55"/>
92
+ </g>
93
+
94
+ <g class="v-glint" stroke="#FFE2A6" stroke-linecap="round">
95
+ <path d="M404 201 h56" stroke-width="2" stroke-opacity="0.7"/>
96
+ <path d="M412 207 h40" stroke-width="1.6" stroke-opacity="0.55"/>
97
+ <path d="M418 214 h28" stroke-width="1.4" stroke-opacity="0.42"/>
98
+ <path d="M423 223 h18" stroke-width="1.2" stroke-opacity="0.3"/>
99
+ <path d="M427 234 h10" stroke-width="1" stroke-opacity="0.2"/>
100
+ </g>
101
+ <g class="v-near">
102
+ <circle cx="74" cy="330" r="22" fill="url(#vLamp)" opacity="0.4"/>
103
+ <circle cx="74" cy="330" r="4.3" fill="#FFF6E2" opacity="0.88"/>
104
+ <circle cx="154" cy="302" r="20" fill="url(#vLamp)" opacity="0.4"/>
105
+ <circle cx="154" cy="302" r="4.0" fill="#FFF6E2" opacity="0.88"/>
106
+ <circle cx="238" cy="340" r="21" fill="url(#vLamp)" opacity="0.4"/>
107
+ <circle cx="238" cy="340" r="4.2" fill="#FFF6E2" opacity="0.88"/>
108
+ <circle cx="312" cy="283" r="18" fill="url(#vLamp)" opacity="0.4"/>
109
+ <circle cx="312" cy="283" r="3.6" fill="#FFF6E2" opacity="0.88"/>
110
+ <circle cx="392" cy="314" r="19" fill="url(#vLamp)" opacity="0.4"/>
111
+ <circle cx="392" cy="314" r="3.8" fill="#FFF6E2" opacity="0.88"/>
112
+ <circle cx="470" cy="268" r="16" fill="url(#vLamp)" opacity="0.4"/>
113
+ <circle cx="470" cy="268" r="3.3" fill="#FFF6E2" opacity="0.88"/>
114
+ <circle cx="552" cy="294" r="18" fill="url(#vLamp)" opacity="0.4"/>
115
+ <circle cx="552" cy="294" r="3.5" fill="#FFF6E2" opacity="0.88"/>
116
+ <circle cx="630" cy="256" r="16" fill="url(#vLamp)" opacity="0.4"/>
117
+ <circle cx="630" cy="256" r="3.1" fill="#FFF6E2" opacity="0.88"/>
118
+ </g>
119
+
120
+ <path d="M0 196 H680" stroke="#FFF1D2" stroke-opacity="0.72" stroke-width="1.5" fill="none"/>
121
+ <text x="664" y="218" text-anchor="end" font-size="10" letter-spacing="0.13em" fill="#FFF4DE" opacity="0.6">HORIZONTE</text>
122
+ </g>
123
+ </svg>
6
124
 
7
125
  Janela is alpha. It works, it is tested against a real Rails application
8
126
  in a real browser, and its public surface has changed in three of the
@@ -39,6 +157,8 @@ A check nobody can prove is working is a check nobody should rely on.
39
157
 
40
158
  ## In 1.0
41
159
 
160
+ The eight lights burning in the near ground.
161
+
42
162
  | Issue | What |
43
163
  | --- | --- |
44
164
  | [#24](https://github.com/retail-tasker/janela/issues/24) | A chart's height and aspect ratio are the host's to set |
@@ -53,10 +173,10 @@ A check nobody can prove is working is a check nobody should rely on.
53
173
  Progress is tracked on the
54
174
  [1.0 milestone](https://github.com/retail-tasker/janela/milestone/2).
55
175
 
56
- ## After 1.0
176
+ ## Horizonte
57
177
 
58
- Wanted, not blocking a stable release. Being on this list is not a
59
- refusal.
178
+ The three lights far off at the horizon. Wanted, not blocking a stable
179
+ release, and being on this list is not a refusal.
60
180
 
61
181
  - **Drill-down on time panes** ([#18](https://github.com/retail-tasker/janela/issues/18)).
62
182
  Clicking a month could filter every other pane to it, or narrow that
@@ -70,10 +190,11 @@ refusal.
70
190
 
71
191
  ## Not coming
72
192
 
73
- Janela is meant to be small enough that forking it and adding your own
74
- piece is a normal way to use it. These are the things you would be
75
- adding yourself, and each is left out because something you already run
76
- does it better.
193
+ The sky above the horizon is empty, and that is the honest part of the
194
+ picture. Janela is meant to be small enough that forking it and adding
195
+ your own piece is a normal way to use it. These are the things you would
196
+ be adding yourself, and each is left out because something you already
197
+ run does it better.
77
198
 
78
199
  Natural-language query. A separate data warehouse. A row-level-security
79
200
  subsystem, because your application already has Pundit or CanCanCan and