studio-engine 0.67.2 → 0.69.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 +45 -0
- data/README.md +48 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +49 -1
- data/app/models/studio/knowledge_doc.rb +6 -0
- data/app/models/studio/knowledge_expectation.rb +91 -0
- data/app/views/studio/knowledge_docs/_browser.html.erb +2 -0
- data/app/views/studio/knowledge_docs/_coverage_table.html.erb +109 -0
- data/app/views/studio/knowledge_docs/coverage.html.erb +4 -0
- data/app/views/studio/knowledge_docs/show.html.erb +9 -0
- data/app/views/style/modals/_auth.html.erb +67 -44
- data/db/migrate/20260902000001_create_studio_knowledge_expectations.rb +32 -0
- data/db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb +9 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +8 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 528dc35d5f8eb65f9c9be8b69d5541d9eaba24bb6e18ec1b2d144274c39f3b75
|
|
4
|
+
data.tar.gz: 92b56fbeda78e7404eaf3ea3832aaa4cc1faa43d88915be60bc4dc7cdc694a3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d95ee9d3c5cf24d466feb69d52e90e763bc7985543740df1458d46dedca41def112f35ce2e3a6642b02ac37a195ac5efe2adec043ecad9516d3e03bd650c156
|
|
7
|
+
data.tar.gz: 4d2d7a231e57e84fb746d0b5db9c77f2780c03c1a7e817273a898bc4484be68d55765bcbc51ccd7e4fc784591bed4fadce17f6e28637ccdbbb713cb0ccf8070c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Knowledge coverage view** — `/admin/knowledge/coverage` +
|
|
10
|
+
`Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
|
|
11
|
+
knowledge layer. Expectations carry an entity, folder, provenance
|
|
12
|
+
(`source_note`, e.g. "diligence tracker item 14"), and a cadence — `once`
|
|
13
|
+
(an LOI) or `monthly` (aging inventory), where each calendar month from
|
|
14
|
+
`start_on` is a slot and the view names missing months outright
|
|
15
|
+
(`2026-09 MISSING`). Documents fulfill expectations by an EXPLICIT
|
|
16
|
+
`expectation_id` set at triage (the show page grows a "fulfills expectation"
|
|
17
|
+
select; a fuzzy name-match would silently merge lookalikes, an id never
|
|
18
|
+
does), and superseded documents never fill a slot. Ships two reference
|
|
19
|
+
migrations (`create_studio_knowledge_expectations`,
|
|
20
|
+
`add_expectation_to_studio_knowledge_docs`); routes ride the existing
|
|
21
|
+
`Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
|
|
22
|
+
65-item diligence tracker; app-agnostic like the rest of the layer.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **The auth modal's Solana button moved to solana-studio, behind a CREDENTIAL
|
|
27
|
+
SLOT.** `style/modals/_auth` no longer draws the button; it renders whatever
|
|
28
|
+
resolves at `solana_studio/auth/wallet_credential` and nothing when that path
|
|
29
|
+
is empty, so bundling the gem IS the registration and a web2 app carries no
|
|
30
|
+
wallet markup at all. This completes the two-template split begun in 0.67.0,
|
|
31
|
+
which moved the four web3 MODALS to the gem and left the sign-in button as the
|
|
32
|
+
last web3 markup in the base engine. The gem half has shipped since
|
|
33
|
+
solana-studio **0.5.2** — until now this engine referenced that partial
|
|
34
|
+
nowhere, so the gem shipped a button no host rendered.
|
|
35
|
+
**Supersedes the stranded PR #245**, whose consumer lane was red only because
|
|
36
|
+
it opened before solana-studio 0.5.2 published.
|
|
37
|
+
|
|
38
|
+
**What did NOT change, deliberately:** `_methodDefaults.wallet` is still
|
|
39
|
+
`Studio.auth_method?(:wallet) && Studio.feature?(:web3)`. The Ruby gate
|
|
40
|
+
answers "is it implemented" (is the picker registered, does the credential
|
|
41
|
+
partial resolve) and gates the RENDER; Alpine's `methodOn('wallet')` still
|
|
42
|
+
answers "should it show" and gates VISIBILITY, which keeps the style guide's
|
|
43
|
+
method toggles working. Folding policy into the Ruby gate would delete the
|
|
44
|
+
button from the DOM on a web3-off app that bundles the gem, and the "or"
|
|
45
|
+
divider — which reads `methodOn('wallet')` too — would then float above a
|
|
46
|
+
button that is not there.
|
|
47
|
+
|
|
48
|
+
Hosts need no change: the engine's auth modal is a style-guide specimen
|
|
49
|
+
reached through `/admin/style`, and an app that bundles solana-studio 0.5.2 or
|
|
50
|
+
later sees the same button in the same place.
|
|
51
|
+
|
|
7
52
|
### Fixed
|
|
8
53
|
|
|
9
54
|
- **Knowledge layer hardening** — the five findings from the 0.67.0 reviews:
|
data/README.md
CHANGED
|
@@ -257,6 +257,54 @@ What the engine **keeps** is the SESSION half of Solana sign-in:
|
|
|
257
257
|
`Studio.wallet_sign_in_statement` — the single source for the signed statement,
|
|
258
258
|
which the gem's deep link reads so the two cannot drift.
|
|
259
259
|
|
|
260
|
+
#### The auth modal's credential slot
|
|
261
|
+
|
|
262
|
+
The **sign-in modal itself stays here** and is never forked: every app in the
|
|
263
|
+
ecosystem signs people in, and most of them are web2. What varies is *which
|
|
264
|
+
credentials it offers*. Google and magic-link are implemented by this engine and
|
|
265
|
+
render directly. The wallet button is implemented by the web3 layer, so it is
|
|
266
|
+
**contributed** — `style/modals/_auth` looks for a partial at one fixed path and
|
|
267
|
+
renders whatever it finds:
|
|
268
|
+
|
|
269
|
+
solana_studio/auth/_wallet_credential (shipped by the solana-studio gem)
|
|
270
|
+
|
|
271
|
+
Bundling the gem **is** the registration. There is no registry call and no
|
|
272
|
+
config flag; an app without that layer renders nothing there, so a web2 app
|
|
273
|
+
carries no wallet markup at all rather than markup hidden behind a flag. This is
|
|
274
|
+
the same optional-partial convention as `modals/_host_extras` above, and it uses
|
|
275
|
+
the same three-term `lookup_context.exists?`.
|
|
276
|
+
|
|
277
|
+
Copying the auth modal into the gem was rejected deliberately: it would fork a
|
|
278
|
+
surface both apps sign in through, and the two copies would drift — which is how
|
|
279
|
+
the wallet picker reached three copies before it was promoted.
|
|
280
|
+
|
|
281
|
+
**Two layers answer two different questions, and they are not merged:**
|
|
282
|
+
|
|
283
|
+
| Question | Answered by | Where it is decided |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| Is it **implemented**? | the picker is registered, **and** the credential partial resolves | Ruby, in `style/modals/_auth` — gates the render |
|
|
286
|
+
| Should it **show**? | `methodOn('wallet')`, falling back to `Studio.auth_method?(:wallet) && Studio.feature?(:web3)` | Alpine, inside the contributed partial — gates visibility |
|
|
287
|
+
|
|
288
|
+
Both terms of the Ruby gate are load-bearing, and they fail differently. Without
|
|
289
|
+
the **registration** term a layer that ships the credential but not the picker
|
|
290
|
+
draws a button that opens an empty panel — not hypothetical, solana-studio 0.5.2
|
|
291
|
+
shipped exactly that pair. Without the **existence** term an app whose picker
|
|
292
|
+
resolves but whose credential does not raises `Missing partial` in front of
|
|
293
|
+
someone signing in, instead of quietly rendering no button.
|
|
294
|
+
|
|
295
|
+
Policy stays **out** of the Ruby gate on purpose. Folding
|
|
296
|
+
`auth_method?(:wallet)` and `feature?(:web3)` into the render would delete the
|
|
297
|
+
button from the DOM on a web3-off app that bundles the gem — and the "or"
|
|
298
|
+
divider reads `methodOn('wallet')` too, so ticking Solana Wallet on the style
|
|
299
|
+
guide's Sign in card would then float a divider above a button that is not
|
|
300
|
+
there.
|
|
301
|
+
|
|
302
|
+
A contributed partial renders inside the modal's own Alpine scope, so it may use
|
|
303
|
+
`methodOn(...)`, `attested()` (the legal-age gate — call it, or wallet becomes
|
|
304
|
+
the one credential that skips attestation) and `props.submitting`. It receives
|
|
305
|
+
one local, `modal_store`: the engine's real host is `"modals"`, the living style
|
|
306
|
+
guide's page-scoped host is `"dsModals"`.
|
|
307
|
+
|
|
260
308
|
It also keeps two blocks the gem renders **by name** across the gem boundary:
|
|
261
309
|
`studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
|
|
262
310
|
Renaming either is a cross-repo change.
|
|
@@ -43,6 +43,47 @@ module Studio
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def show
|
|
46
|
+
# Guarded like intake!'s MissingTable posture: a consumer that bumped the
|
|
47
|
+
# gem without installing the expectations migrations keeps its working
|
|
48
|
+
# doc pages — the link select simply doesn't render.
|
|
49
|
+
@linkable_expectations =
|
|
50
|
+
if Studio::KnowledgeExpectation.table_exists?
|
|
51
|
+
Studio::KnowledgeExpectation.active.for_entity(@doc.entity).order(:path, :title).to_a
|
|
52
|
+
else
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# GET /admin/knowledge/coverage — expected vs uploaded, the gap named.
|
|
58
|
+
def coverage
|
|
59
|
+
unless Studio::KnowledgeExpectation.table_exists?
|
|
60
|
+
return redirect_to admin_knowledge_path,
|
|
61
|
+
alert: "The coverage view needs the studio_knowledge_expectations table — " \
|
|
62
|
+
"run `bin/rails studio_engine:install:migrations && bin/rails db:migrate`."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@entity = params[:entity].presence
|
|
66
|
+
scope = Studio::KnowledgeExpectation.active.order(:path, :title)
|
|
67
|
+
scope = scope.for_entity(@entity) if @entity
|
|
68
|
+
@expectations = scope.to_a
|
|
69
|
+
@entities = Studio::KnowledgeExpectation.distinct.pluck(:entity).sort
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_expectation
|
|
73
|
+
expectation = Studio::KnowledgeExpectation.create!(expectation_params)
|
|
74
|
+
redirect_to admin_knowledge_coverage_path(entity: expectation.entity),
|
|
75
|
+
notice: "Expecting: #{expectation.title}."
|
|
76
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
77
|
+
redirect_to admin_knowledge_coverage_path, alert: e.message
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update_expectation
|
|
81
|
+
expectation = Studio::KnowledgeExpectation.find(params[:id])
|
|
82
|
+
expectation.update!(expectation_params)
|
|
83
|
+
redirect_to admin_knowledge_coverage_path(entity: expectation.entity),
|
|
84
|
+
notice: "#{expectation.title} updated."
|
|
85
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
86
|
+
redirect_to admin_knowledge_coverage_path, alert: e.message
|
|
46
87
|
end
|
|
47
88
|
|
|
48
89
|
def create
|
|
@@ -73,6 +114,12 @@ module Studio
|
|
|
73
114
|
|
|
74
115
|
private
|
|
75
116
|
|
|
117
|
+
def expectation_params
|
|
118
|
+
params.require(:knowledge_expectation)
|
|
119
|
+
.permit(:entity, :title, :path, :category, :cadence, :start_on,
|
|
120
|
+
:source_note, :active)
|
|
121
|
+
end
|
|
122
|
+
|
|
76
123
|
def set_doc
|
|
77
124
|
@doc = Studio::KnowledgeDoc.find(params[:id])
|
|
78
125
|
end
|
|
@@ -80,7 +127,8 @@ module Studio
|
|
|
80
127
|
def doc_params
|
|
81
128
|
permitted = params.require(:knowledge_doc)
|
|
82
129
|
.permit(:title, :entity, :path, :category, :summary,
|
|
83
|
-
:document_date, :source_note, :status,
|
|
130
|
+
:document_date, :source_note, :status,
|
|
131
|
+
:expectation_id, access: {})
|
|
84
132
|
# The access map arrives as {"samson" => "full", ...}; drop blanks so an
|
|
85
133
|
# untouched select doesn't write a "none" the map means by absence anyway.
|
|
86
134
|
if permitted[:access]
|
|
@@ -35,6 +35,12 @@ module Studio
|
|
|
35
35
|
ACCESS_LEVELS = %w[full aware none].freeze
|
|
36
36
|
STATUSES = %w[inbox filed superseded].freeze
|
|
37
37
|
|
|
38
|
+
# The expectation this document fulfills (coverage view) — optional, set at
|
|
39
|
+
# triage. Guarded: consumers that installed the docs table before the
|
|
40
|
+
# expectations migration existed simply have no column yet.
|
|
41
|
+
belongs_to :expectation, class_name: "Studio::KnowledgeExpectation",
|
|
42
|
+
optional: true, inverse_of: :docs
|
|
43
|
+
|
|
38
44
|
validates :title, presence: true
|
|
39
45
|
validates :entity, presence: true
|
|
40
46
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# One row per document the knowledge layer EXPECTS — the other half of the
|
|
3
|
+
# coverage question. Documents record what exists; expectations record what
|
|
4
|
+
# should, so the gap between them is computable instead of remembered.
|
|
5
|
+
#
|
|
6
|
+
# Two cadences:
|
|
7
|
+
#
|
|
8
|
+
# once — a single fulfilling document satisfies it forever (an LOI, a
|
|
9
|
+
# lease, tracker item 14).
|
|
10
|
+
# monthly — one document per calendar month from start_on onward (aging
|
|
11
|
+
# inventory, bank statements). Each month is a SLOT; a slot is
|
|
12
|
+
# filled when a linked doc's as-of date falls inside it, and the
|
|
13
|
+
# coverage view names the missing months outright.
|
|
14
|
+
#
|
|
15
|
+
# Fulfillment is an EXPLICIT link — Studio::KnowledgeDoc#expectation_id, set
|
|
16
|
+
# at triage — never a name match: a fuzzy matcher silently merges lookalike
|
|
17
|
+
# documents, an id never does.
|
|
18
|
+
#
|
|
19
|
+
# Installed per consumer like the other studio_* tables.
|
|
20
|
+
class KnowledgeExpectation < ApplicationRecord
|
|
21
|
+
self.table_name = "studio_knowledge_expectations"
|
|
22
|
+
|
|
23
|
+
CADENCES = %w[once monthly].freeze
|
|
24
|
+
|
|
25
|
+
has_many :docs, class_name: "Studio::KnowledgeDoc",
|
|
26
|
+
foreign_key: :expectation_id, inverse_of: :expectation,
|
|
27
|
+
dependent: :nullify
|
|
28
|
+
|
|
29
|
+
validates :entity, presence: true
|
|
30
|
+
validates :title, presence: true
|
|
31
|
+
validates :cadence, inclusion: { in: CADENCES }
|
|
32
|
+
|
|
33
|
+
before_validation :normalize_fields
|
|
34
|
+
|
|
35
|
+
scope :for_entity, ->(entity) { where(entity: entity) }
|
|
36
|
+
scope :active, -> { where(active: true) }
|
|
37
|
+
|
|
38
|
+
def monthly?
|
|
39
|
+
cadence == "monthly"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The linked documents that can fill slots — superseded rows never count.
|
|
43
|
+
def fulfilling_docs
|
|
44
|
+
docs.active.to_a
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Monthly slot months, oldest first: start_on's month (or this row's
|
|
48
|
+
# creation month) through as_of's month. Nil for once-cadence.
|
|
49
|
+
def slot_months(as_of: Date.current)
|
|
50
|
+
return nil unless monthly?
|
|
51
|
+
|
|
52
|
+
first = (start_on || created_at&.to_date || as_of).beginning_of_month
|
|
53
|
+
last = as_of.beginning_of_month
|
|
54
|
+
return [] if first > last
|
|
55
|
+
|
|
56
|
+
months = []
|
|
57
|
+
cursor = first
|
|
58
|
+
while cursor <= last
|
|
59
|
+
months << cursor
|
|
60
|
+
cursor = cursor.next_month
|
|
61
|
+
end
|
|
62
|
+
months
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Coverage for the view, one shape per cadence:
|
|
66
|
+
# once: { filled: bool, docs: [...] }
|
|
67
|
+
# monthly: { slots: [{ month:, docs: [...] }], missing_months: [...] }
|
|
68
|
+
def coverage(as_of: Date.current)
|
|
69
|
+
linked = fulfilling_docs
|
|
70
|
+
return { filled: linked.any?, docs: linked } unless monthly?
|
|
71
|
+
|
|
72
|
+
slots = slot_months(as_of: as_of).map do |month|
|
|
73
|
+
{ month: month,
|
|
74
|
+
docs: linked.select { |doc| doc.display_date&.beginning_of_month == month } }
|
|
75
|
+
end
|
|
76
|
+
{ slots: slots, missing_months: slots.select { |slot| slot[:docs].empty? }.map { |slot| slot[:month] } }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def filled?(as_of: Date.current)
|
|
80
|
+
result = coverage(as_of: as_of)
|
|
81
|
+
monthly? ? result[:missing_months].empty? : result[:filled]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def normalize_fields
|
|
87
|
+
self.entity = entity.to_s.strip.downcase if entity
|
|
88
|
+
self.path = Studio::KnowledgeDoc.normalize_path(path)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
<span aria-hidden="true">·</span>
|
|
44
44
|
<%= link_to "Show all", admin_knowledge_path(view: "flat", entity: entity, status: status),
|
|
45
45
|
class: view == "flat" ? "font-semibold underline" : "" %>
|
|
46
|
+
<span aria-hidden="true">·</span>
|
|
47
|
+
<%= link_to "Coverage", admin_knowledge_coverage_path(entity: entity), id: "knowledge-coverage-link" %>
|
|
46
48
|
</div>
|
|
47
49
|
</div>
|
|
48
50
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<%# The coverage table — expected vs uploaded, gaps named. Locals:
|
|
2
|
+
expectations: active Studio::KnowledgeExpectation rows to render
|
|
3
|
+
entity: entity filter or nil
|
|
4
|
+
entities: all entity slugs (filter row) %>
|
|
5
|
+
<style>
|
|
6
|
+
.coverage-mark { font-size: .8rem; padding: 0 .45rem; border-radius: .25rem; white-space: nowrap; }
|
|
7
|
+
.coverage-filled { color: rgb(5 150 105); }
|
|
8
|
+
.coverage-missing { color: rgb(220 38 38); border: 1px solid rgb(220 38 38 / .5); font-weight: 600; }
|
|
9
|
+
.coverage-slot { display: inline-block; margin-right: .4rem; }
|
|
10
|
+
</style>
|
|
11
|
+
|
|
12
|
+
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
13
|
+
<h1 class="text-xl font-semibold">Coverage</h1>
|
|
14
|
+
<div class="text-sm"><%= link_to "← Knowledge", admin_knowledge_path(entity: entity) %></div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<% if entities.length > 1 || entity %>
|
|
18
|
+
<div class="flex flex-wrap items-center gap-2 mb-3 text-sm">
|
|
19
|
+
<%= link_to "All entities", admin_knowledge_coverage_path, class: entity ? "" : "font-semibold underline" %>
|
|
20
|
+
<% entities.each do |candidate| %>
|
|
21
|
+
<%= link_to candidate, admin_knowledge_coverage_path(entity: candidate),
|
|
22
|
+
class: candidate == entity ? "font-semibold underline" : "" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<% if expectations.any? %>
|
|
28
|
+
<div class="overflow-x-auto mb-6">
|
|
29
|
+
<table id="knowledge-coverage" class="w-full text-sm">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr class="text-left border-b">
|
|
32
|
+
<th class="py-2 pr-3">Expected</th>
|
|
33
|
+
<th class="py-2 pr-3">Folder</th>
|
|
34
|
+
<th class="py-2 pr-3">Cadence</th>
|
|
35
|
+
<th class="py-2 pr-3">Coverage</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<% expectations.each do |expectation| %>
|
|
40
|
+
<% result = expectation.coverage %>
|
|
41
|
+
<tr id="coverage-exp-<%= expectation.id %>" class="border-b align-top">
|
|
42
|
+
<td class="py-2 pr-3">
|
|
43
|
+
<span class="font-medium"><%= expectation.title %></span>
|
|
44
|
+
<% if expectation.source_note.present? %>
|
|
45
|
+
<div class="text-xs opacity-60"><%= expectation.source_note %></div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</td>
|
|
48
|
+
<td class="py-2 pr-3 text-xs opacity-80"><%= expectation.path.presence || "root" %></td>
|
|
49
|
+
<td class="py-2 pr-3"><%= expectation.cadence %></td>
|
|
50
|
+
<td class="py-2 pr-3">
|
|
51
|
+
<% if expectation.monthly? %>
|
|
52
|
+
<% result[:slots].each do |slot| %>
|
|
53
|
+
<span class="coverage-slot coverage-mark <%= slot[:docs].any? ? "coverage-filled" : "coverage-missing" %>"
|
|
54
|
+
id="coverage-slot-<%= expectation.id %>-<%= slot[:month].strftime("%Y-%m") %>">
|
|
55
|
+
<%= slot[:month].strftime("%Y-%m") %> <%= slot[:docs].any? ? "✓" : "MISSING" %>
|
|
56
|
+
</span>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% elsif result[:filled] %>
|
|
59
|
+
<span class="coverage-mark coverage-filled">✓ filled —
|
|
60
|
+
<% result[:docs].first(2).each do |doc| %>
|
|
61
|
+
<%= link_to doc.title, admin_knowledge_doc_path(doc) %>
|
|
62
|
+
<% end %>
|
|
63
|
+
</span>
|
|
64
|
+
<% else %>
|
|
65
|
+
<span class="coverage-mark coverage-missing">MISSING</span>
|
|
66
|
+
<% end %>
|
|
67
|
+
</td>
|
|
68
|
+
</tr>
|
|
69
|
+
<% end %>
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</div>
|
|
73
|
+
<% else %>
|
|
74
|
+
<p id="coverage-empty" class="text-sm opacity-70 mb-4">Nothing is expected yet — add the first expectation below.</p>
|
|
75
|
+
<% end %>
|
|
76
|
+
|
|
77
|
+
<div id="coverage-new" class="p-4 border rounded">
|
|
78
|
+
<h2 class="font-semibold mb-3">Expect a document</h2>
|
|
79
|
+
<%= form_with url: admin_knowledge_expectations_path, method: :post, local: true do |form| %>
|
|
80
|
+
<div class="grid gap-3 md:grid-cols-3">
|
|
81
|
+
<label class="block text-sm">
|
|
82
|
+
<span class="block mb-1">Title</span>
|
|
83
|
+
<%= form.text_field "knowledge_expectation[title]", required: true, class: "block w-full border rounded px-2 py-1" %>
|
|
84
|
+
</label>
|
|
85
|
+
<label class="block text-sm">
|
|
86
|
+
<span class="block mb-1">Entity</span>
|
|
87
|
+
<%= form.text_field "knowledge_expectation[entity]", value: entity, required: true, class: "block w-full border rounded px-2 py-1" %>
|
|
88
|
+
</label>
|
|
89
|
+
<label class="block text-sm">
|
|
90
|
+
<span class="block mb-1">Folder</span>
|
|
91
|
+
<%= form.text_field "knowledge_expectation[path]", class: "block w-full border rounded px-2 py-1" %>
|
|
92
|
+
</label>
|
|
93
|
+
<label class="block text-sm">
|
|
94
|
+
<span class="block mb-1">Cadence</span>
|
|
95
|
+
<%= form.select "knowledge_expectation[cadence]",
|
|
96
|
+
[["once", "once"], ["monthly", "monthly"]], {}, class: "block w-full border rounded px-2 py-1" %>
|
|
97
|
+
</label>
|
|
98
|
+
<label class="block text-sm">
|
|
99
|
+
<span class="block mb-1">Starts <span class="opacity-60">(monthly only)</span></span>
|
|
100
|
+
<%= form.date_field "knowledge_expectation[start_on]", class: "block w-full border rounded px-2 py-1" %>
|
|
101
|
+
</label>
|
|
102
|
+
<label class="block text-sm">
|
|
103
|
+
<span class="block mb-1">Source <span class="opacity-60">("tracker item 14")</span></span>
|
|
104
|
+
<%= form.text_field "knowledge_expectation[source_note]", class: "block w-full border rounded px-2 py-1" %>
|
|
105
|
+
</label>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="mt-3"><%= form.submit "Add expectation", class: "px-3 py-1.5 border rounded font-medium" %></div>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
class: "block w-full border rounded px-2 py-1" %>
|
|
70
70
|
</label>
|
|
71
71
|
<% end %>
|
|
72
|
+
<% expectations = @linkable_expectations || [] %>
|
|
73
|
+
<% if expectations.any? %>
|
|
74
|
+
<label class="block text-sm" id="knowledge-expectation-select">
|
|
75
|
+
<span class="block mb-1">Fulfills expectation</span>
|
|
76
|
+
<%= form.select "knowledge_doc[expectation_id]",
|
|
77
|
+
[["— none —", ""]] + expectations.map { |e| [ "#{e.title} (#{e.cadence})", e.id ] },
|
|
78
|
+
{ selected: @doc.expectation_id }, class: "block w-full border rounded px-2 py-1" %>
|
|
79
|
+
</label>
|
|
80
|
+
<% end %>
|
|
72
81
|
<label class="block text-sm md:col-span-2">
|
|
73
82
|
<span class="block mb-1">Summary (safe for "aware" agents)</span>
|
|
74
83
|
<%= form.text_area "knowledge_doc[summary]", value: @doc.summary, rows: 3, class: "block w-full border rounded px-2 py-1" %>
|
|
@@ -18,26 +18,69 @@
|
|
|
18
18
|
No real auth happens — the demo shows the real UI.
|
|
19
19
|
|
|
20
20
|
LOCALS:
|
|
21
|
-
web3_gem (Boolean, required) — does solana-studio resolve
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
on purpose: a caller that forgets it should fail loudly here rather than
|
|
21
|
+
web3_gem (Boolean, required) — does solana-studio resolve, meaning is the
|
|
22
|
+
wallet-connect picker on the view path. style/_modals owns the lookup and
|
|
23
|
+
registers wallet-connect behind the same flag. Fetched without a default on
|
|
24
|
+
purpose: a caller that forgets it should fail loudly here rather than
|
|
26
25
|
silently drop Solana sign-in from an app that has the gem.
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
app
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
THE CREDENTIAL SLOT. Google and magic-link are implemented by this engine, so
|
|
28
|
+
they render below directly. Wallet is not: it belongs to the web3 bolt-on, and
|
|
29
|
+
a base-template app — a newsletter, a hub — must not carry markup it can never
|
|
30
|
+
render. So the wallet button is CONTRIBUTED by whichever layer implements it,
|
|
31
|
+
as a partial this file merely looks for:
|
|
32
|
+
|
|
33
|
+
solana_studio/auth/wallet_credential
|
|
34
|
+
|
|
35
|
+
Bundling the gem IS the registration. An app without that layer renders
|
|
36
|
+
nothing here and carries no wallet markup at all, rather than shipping a
|
|
37
|
+
button hidden behind a flag.
|
|
38
|
+
|
|
39
|
+
TWO LAYERS ANSWER TWO DIFFERENT QUESTIONS, and they are deliberately NOT
|
|
40
|
+
merged into one:
|
|
41
|
+
|
|
42
|
+
Ruby, below IS IT IMPLEMENTED. Is the picker this button swaps to
|
|
43
|
+
registered (web3_gem), and does a layer actually ship the
|
|
44
|
+
credential partial. This gates the RENDER.
|
|
45
|
+
Alpine, in the SHOULD IT SHOW. methodOn('wallet'), which reads
|
|
46
|
+
contributed props.methods first so the Sign in card's toggle overrides
|
|
47
|
+
partial the server default outright. This gates VISIBILITY.
|
|
48
|
+
|
|
49
|
+
KEEPING POLICY OUT OF THE RUBY GATE IS LOAD-BEARING, and it is why
|
|
50
|
+
auth_method?(:wallet) and feature?(:web3) are NOT terms in the render gate.
|
|
51
|
+
Fold them in and the button leaves the DOM on a web3-off app that bundles the
|
|
52
|
+
gem — and then ticking Solana Wallet on the Sign in card turns the divider
|
|
53
|
+
below on above a button that is not there, because the divider reads
|
|
54
|
+
methodOn('wallet') too. Measured: that is the exact failure this slot was
|
|
55
|
+
meant to remove, reintroduced one layer up.
|
|
56
|
+
|
|
57
|
+
NOT gated on the slot, deliberately: _methodDefaults.wallet below. It stays
|
|
58
|
+
the app's own policy answer and is unchanged by the move. It is the fallback
|
|
59
|
+
methodOn uses when an opener passes no methods hash, and on a base app nothing
|
|
60
|
+
reaches it — the Sign in card always passes explicit booleans, and the only
|
|
61
|
+
opener that omits them is the wallet picker's back button, which needs the gem
|
|
62
|
+
to exist at all.
|
|
35
63
|
|
|
36
64
|
CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
|
|
37
65
|
SINGLE root, so everything lives inside the outer <div>. The x-data is a
|
|
38
66
|
double-quoted attribute: keep it free of double-quotes and backticks.
|
|
39
67
|
%>
|
|
40
68
|
<% web3_gem = local_assigns.fetch(:web3_gem) %>
|
|
69
|
+
<%# BOTH terms are load-bearing, and they fail differently.
|
|
70
|
+
|
|
71
|
+
web3_gem is the picker this button swaps to actually REGISTERED. A layer
|
|
72
|
+
that ships the credential partial without the picker draws a button that
|
|
73
|
+
opens an empty panel — not hypothetical: solana-studio 0.5.2 shipped
|
|
74
|
+
exactly that pair, the credential without wallet_connect.
|
|
75
|
+
exists? does a layer ship the button AT ALL. Without it, an app whose
|
|
76
|
+
picker resolves but whose credential does not raises Missing partial in
|
|
77
|
+
front of someone signing in, instead of rendering no button.
|
|
78
|
+
|
|
79
|
+
Same three-term lookup_context.exists? the modal host uses for host_extras
|
|
80
|
+
(studio/modals/_host.html.erb): name, prefixes, partial. %>
|
|
81
|
+
<% wallet_credential = "solana_studio/auth/wallet_credential" %>
|
|
82
|
+
<% wallet_credential_available = web3_gem &&
|
|
83
|
+
lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true) %>
|
|
41
84
|
<div x-data="{
|
|
42
85
|
email: '',
|
|
43
86
|
ageAttested: false,
|
|
@@ -85,10 +128,6 @@
|
|
|
85
128
|
if (pp) pp.submitting = null;
|
|
86
129
|
}, 1200);
|
|
87
130
|
},
|
|
88
|
-
openWalletHub() {
|
|
89
|
-
if (!this.attested()) return;
|
|
90
|
-
Alpine.store('dsModals').swap('wallet-connect', { backTo: 'auth', ageAttested: true });
|
|
91
|
-
},
|
|
92
131
|
async submitMagicLink() {
|
|
93
132
|
if (!this.attested()) return;
|
|
94
133
|
var p = this.props;
|
|
@@ -184,35 +223,19 @@
|
|
|
184
223
|
<p role="alert" class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
|
|
185
224
|
</template>
|
|
186
225
|
|
|
187
|
-
<%# 2.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
server default outright.
|
|
194
|
-
Ruby web3_gem CAPABILITY — does solana-studio resolve, so
|
|
195
|
-
that something actually REGISTERED the wallet-connect id this button
|
|
196
|
-
swaps to. style/_modals wraps that registration in the same gate.
|
|
226
|
+
<%# 2. Wallet — CONTRIBUTED, not written here. See the credential-slot note
|
|
227
|
+
at the top of this file. The partial owns the button, its brand mark
|
|
228
|
+
and its click handler; this engine owns only the decision to look for
|
|
229
|
+
it. An app with no web3 layer on its view path renders nothing at all
|
|
230
|
+
here, which is the whole point: the base template ships no wallet
|
|
231
|
+
markup.
|
|
197
232
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
<% if
|
|
203
|
-
|
|
204
|
-
class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
|
|
205
|
-
<svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
|
|
206
|
-
<defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
|
|
207
|
-
<stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
|
|
208
|
-
</linearGradient></defs>
|
|
209
|
-
<path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#auth-solana-grad)"/>
|
|
210
|
-
<path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#auth-solana-grad)"/>
|
|
211
|
-
<path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#auth-solana-grad)"/>
|
|
212
|
-
</svg>
|
|
213
|
-
<span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
|
|
214
|
-
<span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
|
|
215
|
-
</button>
|
|
233
|
+
Visibility is still Alpine's. The contributed partial carries
|
|
234
|
+
x-show methodOn('wallet'), so the Sign in card's toggle drives it
|
|
235
|
+
exactly as it drove the button that used to live here, and the divider
|
|
236
|
+
below can never float above a button the DOM is missing. %>
|
|
237
|
+
<% if wallet_credential_available %>
|
|
238
|
+
<%= render wallet_credential, modal_store: "dsModals" %>
|
|
216
239
|
<% end %>
|
|
217
240
|
|
|
218
241
|
<%# "or" divider — only when magic-link AND a social method are both on. %>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Reference migration for Studio::KnowledgeExpectation — the "what SHOULD
|
|
2
|
+
# exist" half of the knowledge layer's coverage view. Installed per consumer
|
|
3
|
+
# like the other studio_* tables (`bin/rails studio_engine:install:migrations
|
|
4
|
+
# && bin/rails db:migrate`); do not hand-copy.
|
|
5
|
+
#
|
|
6
|
+
# STYLE NOTE: reference migrations are written omakase-rubocop-compatible
|
|
7
|
+
# (spaces inside array brackets) because the installed copy is linted by the
|
|
8
|
+
# CONSUMER's rubocop, not this gem's.
|
|
9
|
+
class CreateStudioKnowledgeExpectations < ActiveRecord::Migration[7.2]
|
|
10
|
+
def change
|
|
11
|
+
create_table :studio_knowledge_expectations do |t|
|
|
12
|
+
# Which business expects the document (same vocabulary as knowledge docs).
|
|
13
|
+
t.string :entity, null: false
|
|
14
|
+
t.string :title, null: false
|
|
15
|
+
# Folder the fulfilling documents live under; display + default, not a matcher.
|
|
16
|
+
t.string :path, null: false, default: ""
|
|
17
|
+
t.string :category
|
|
18
|
+
# once: a single document satisfies it forever.
|
|
19
|
+
# monthly: one document per calendar month from start_on onward.
|
|
20
|
+
t.string :cadence, null: false, default: "once"
|
|
21
|
+
# First month a monthly expectation owes a document (defaults to creation month).
|
|
22
|
+
t.date :start_on
|
|
23
|
+
# Provenance: "diligence tracker item 14", "lender checklist", …
|
|
24
|
+
t.string :source_note
|
|
25
|
+
t.boolean :active, null: false, default: true
|
|
26
|
+
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_index :studio_knowledge_expectations, [ :entity, :active ]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Links a knowledge doc to the expectation it fulfills — set at triage, an
|
|
2
|
+
# EXPLICIT pointer rather than name-matching (a fuzzy match silently merges
|
|
3
|
+
# lookalikes; an id never does). Installed per consumer; do not hand-copy.
|
|
4
|
+
class AddExpectationToStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
|
|
5
|
+
def change
|
|
6
|
+
add_column :studio_knowledge_docs, :expectation_id, :bigint
|
|
7
|
+
add_index :studio_knowledge_docs, :expectation_id
|
|
8
|
+
end
|
|
9
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -861,6 +861,14 @@ module Studio
|
|
|
861
861
|
if Studio.draw_knowledge_routes
|
|
862
862
|
get "admin/knowledge", to: "studio/knowledge_docs#index", as: :admin_knowledge
|
|
863
863
|
post "admin/knowledge", to: "studio/knowledge_docs#create"
|
|
864
|
+
# Coverage (expected vs uploaded) — MUST precede the :id route below,
|
|
865
|
+
# or "coverage" is swallowed as a document id.
|
|
866
|
+
get "admin/knowledge/coverage", to: "studio/knowledge_docs#coverage",
|
|
867
|
+
as: :admin_knowledge_coverage
|
|
868
|
+
post "admin/knowledge/expectations", to: "studio/knowledge_docs#create_expectation",
|
|
869
|
+
as: :admin_knowledge_expectations
|
|
870
|
+
patch "admin/knowledge/expectations/:id", to: "studio/knowledge_docs#update_expectation",
|
|
871
|
+
as: :admin_knowledge_expectation
|
|
864
872
|
get "admin/knowledge/:id", to: "studio/knowledge_docs#show", as: :admin_knowledge_doc
|
|
865
873
|
patch "admin/knowledge/:id", to: "studio/knowledge_docs#update"
|
|
866
874
|
get "admin/knowledge/:id/download", to: "studio/knowledge_docs#download",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.69.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -363,6 +363,7 @@ files:
|
|
|
363
363
|
- app/models/studio/enumeral.rb
|
|
364
364
|
- app/models/studio/geo_setting.rb
|
|
365
365
|
- app/models/studio/knowledge_doc.rb
|
|
366
|
+
- app/models/studio/knowledge_expectation.rb
|
|
366
367
|
- app/models/studio/link.rb
|
|
367
368
|
- app/models/studio/model_page.rb
|
|
368
369
|
- app/models/theme_setting.rb
|
|
@@ -441,8 +442,10 @@ files:
|
|
|
441
442
|
- app/views/studio/knowledge_docs/_access_chips.html.erb
|
|
442
443
|
- app/views/studio/knowledge_docs/_browser.html.erb
|
|
443
444
|
- app/views/studio/knowledge_docs/_chip_styles.html.erb
|
|
445
|
+
- app/views/studio/knowledge_docs/_coverage_table.html.erb
|
|
444
446
|
- app/views/studio/knowledge_docs/_doc_row.html.erb
|
|
445
447
|
- app/views/studio/knowledge_docs/_upload_form.html.erb
|
|
448
|
+
- app/views/studio/knowledge_docs/coverage.html.erb
|
|
446
449
|
- app/views/studio/knowledge_docs/index.html.erb
|
|
447
450
|
- app/views/studio/knowledge_docs/show.html.erb
|
|
448
451
|
- app/views/studio/links/confirm.html.erb
|
|
@@ -554,6 +557,8 @@ files:
|
|
|
554
557
|
- db/migrate/20260813220000_add_standard_user_profile_columns.rb
|
|
555
558
|
- db/migrate/20260818120000_create_studio_geo_settings.rb
|
|
556
559
|
- db/migrate/20260901000001_create_studio_knowledge_docs.rb
|
|
560
|
+
- db/migrate/20260902000001_create_studio_knowledge_expectations.rb
|
|
561
|
+
- db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
|
|
557
562
|
- lib/studio-engine.rb
|
|
558
563
|
- lib/studio.rb
|
|
559
564
|
- lib/studio/cable.rb
|