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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79895e24dd5ade937ec8c0b4afacd84605ad495530a4c37f94e053a2c1040170
4
- data.tar.gz: 0f1716ef07d10fc5388ea15853767f6c7e8911e076e280a096a8424a494eba9c
3
+ metadata.gz: 528dc35d5f8eb65f9c9be8b69d5541d9eaba24bb6e18ec1b2d144274c39f3b75
4
+ data.tar.gz: 92b56fbeda78e7404eaf3ea3832aaa4cc1faa43d88915be60bc4dc7cdc694a3e
5
5
  SHA512:
6
- metadata.gz: 7c6e391302c996753b399019f2baeded267820a69d1f7451fe7ff68f82609fc625cc477cbdebb6127f74d0d5ed3a326ed4357d02c24b595e0fd1c213c3605895
7
- data.tar.gz: 26bda400a871f3998a7baa64c72f43ed71a2d6d4ff177e02c4c6ae76d9bff65438e0bb33d48564802ea982bc2f628631906fc25e0a24167ec11d38796908310d
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, access: {})
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>
@@ -0,0 +1,4 @@
1
+ <%# /admin/knowledge/coverage — bare content wrapper; the table partial is the
2
+ primitive (renderable + testable on its own, like the browser). %>
3
+ <%= render "studio/knowledge_docs/coverage_table",
4
+ expectations: @expectations, entity: @entity, entities: @entities %>
@@ -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. style/_modals
22
- owns the lookup and registers wallet-connect behind the same flag; the
23
- Solana button below is rendered only when it is true, because a button that
24
- swaps to an unregistered id opens an empty panel. Fetched without a default
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
- NOT gated on web3_gem, deliberately: _methodDefaults.wallet below. It is the
29
- fallback methodOn uses when an opener passes no methods hash, and on a base
30
- app nothing reaches it the Sign in card always passes explicit booleans, and
31
- the only opener that omits them is the wallet picker's back button, which
32
- needs the gem to exist at all. Adding the term there would read like a third
33
- gate while changing no rendered outcome and no behaviour, and a gate that
34
- cannot be shown to bite is decoration. The trigger below is the real gate.
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. Solanaprogresses to the Connect Wallet picker modal. TWO gates,
188
- asking two different questions, and only one of them is Alpine's:
189
-
190
- x-show methodOn('wallet') POLICY does this app offer wallet as a
191
- credential, and has the specimen card's toggle turned it on. Reads
192
- props.methods.wallet first, so the card's checkbox overrides the
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. WalletCONTRIBUTED, 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
- The policy gate cannot stand in for the capability one: a base app can
199
- answer yes to the first and no to the second, and then the button opens
200
- an empty panel. It has to be the RUBY gate because the toggle can flip
201
- every Alpine term, and a control the operator can tick is not a gate. %>
202
- <% if web3_gem %>
203
- <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.67.2"
2
+ VERSION = "0.69.0"
3
3
  end
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.67.2
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