studio-engine 0.69.4 → 0.70.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: 12d0b8a6c255a4f9e18d6a3a588abefdd7dd5d54a3801deb9b52eb99da2d78fe
4
- data.tar.gz: 3467bf1af09d79805053d721c1d0d6028fd0c5b0f806c94d6a2cdc7864f048f1
3
+ metadata.gz: f1c217135776ee0fcd67dd4ed2e9a72f1aedf5260ac23cfc23615ab83754133a
4
+ data.tar.gz: 42878ae8f85ed0c911b99c940e3c5094ca7329fed4bbb727ebfa4466bba99dfb
5
5
  SHA512:
6
- metadata.gz: 3fd8ea14026738113f2348ea267b2cde306afa6a37d1b37dbf23e426013cb632be62164413e64516893f913e9f8a1618559ef326187e328001d6effceffe4187
7
- data.tar.gz: 1498813d632e019fc574a6467e87fa73965f0e018001c845cac52a22d21443cfad8426e50a94ec9d37e874a6f1d059770dd97696665141853fb2a9c458b97242
6
+ metadata.gz: d72745a5c54559b745fc0fefe1ac1c45b3921efeccbe5769f3b1da5ceaa1e11463c317984847fc1300fb0387610c33c4f0c7504e279871630f5535db563c935e
7
+ data.tar.gz: 16be22a876c99c848169a2dcd551593ff4b8c377427acd9d84c04bf732847d261422c79d9b7f30397bf8b04c2d13a894999cc542a95bb1037f9deeaea0c5c32a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
 
9
+ - **A `required` mode on the shared first-name card.**
10
+ `studio/modals/onboarding/_first_name` grows one local. `required: true`
11
+ renders the card with BOTH skip affordances gone — the "Skip for now" button
12
+ is not emitted at all, and the × merely CLOSES and is labelled `Close` — and
13
+ swaps the sub-copy to the audience being gated. Default `false`, so the
14
+ post-auth chain card McRitchie Studio renders today does not move.
15
+
16
+ **IT IS FOR A HOST THAT GATES SOMETHING ON THE NAME.** turf-monster's entry
17
+ gate opens this card as the first validation of hold-to-confirm, and that gate
18
+ reads the stored COLUMN — so a recorded skip buys the user nothing and the
19
+ Skip link is a door painted on a wall, promising a way past a wall that does
20
+ not move.
21
+
22
+ **IT IS DELIBERATELY NOT A TRAP.** Closing stays reachable, because abandoning
23
+ the flow and slipping past the gate are two different things: the × still
24
+ dismisses the card, it just stops calling `skip()` and stops claiming it did.
25
+ A `required` that also removed the × would satisfy every "hides the skip"
26
+ reading and be a worse card, so the two are asserted as a PAIR.
27
+
28
+ **RESOLVED SERVER-SIDE, not through an Alpine `x-show`** — the source this was
29
+ ported from hides its skip button reactively, which leaves a button that must
30
+ not exist sitting in the DOM and clickable for as long as Alpine has not
31
+ mounted, and there for good if it never does. `required` is known at render
32
+ time, so the button is simply never emitted.
33
+
34
+ The default render is asserted **byte-for-byte identical** to `required:
35
+ false`, which is what pins the existing consumer. Mutation-checked 5/5:
36
+ flipping the default, un-hiding the skip button, making the × always skip,
37
+ freezing the label, and freezing the sub-copy each turn the suite red on a
38
+ different test.
39
+
40
+ This was the LAST mode turf-monster's own 240-line `modals/_onboarding` had
41
+ that the gem did not, so that copy can be deleted next release. A host wanting
42
+ both modes mounts two `<template x-if>` ids rather than branching one at
43
+ runtime — which is how the two cards already differ in copy, not just in
44
+ affordances.
45
+
9
46
  - **Knowledge coverage view** — `/admin/knowledge/coverage` +
10
47
  `Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
11
48
  knowledge layer. Expectations carry an entity, folder, provenance
@@ -21,6 +58,41 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
21
58
  `Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
22
59
  65-item diligence tracker; app-agnostic like the rest of the layer.
23
60
 
61
+ - **The style guide grows the two modal simulators that review ENGINE
62
+ behaviour** — `#modals-motion-registry` ("Enter & leave animations") and
63
+ `#modals-stack-mechanics` ("Stack behaviour") in `style/_modals`. Both are
64
+ ported from turf-monster's `/admin/modals` gallery, which is being deleted:
65
+ the rest of that page was consumer showroom, but these two exercised engine
66
+ mechanics — the `window.ModalAnimations` registry, and the modal stack's
67
+ dismissibility / `holdAtLeast` / LIFO rules — and would have gone with it.
68
+
69
+ The motion section BUILDS ITS CONTROLS FROM THE LIVE REGISTRY at load
70
+ (a button and a dropdown option per registered key, per channel), so
71
+ registering a new animation surfaces it on the guide with no edit to the
72
+ page. That is the property the section exists for, and
73
+ `test/views/style_guide_modal_simulator_test.rb` pins it from both sides:
74
+ the containers must ship EMPTY, and the build must enumerate
75
+ `window.ModalAnimations` rather than a local literal.
76
+
77
+ The stack demos drive `$store.dsModals` and the guide's own `onchain-tx`
78
+ specimen directly. **One demo did not come across: "error with recovery."**
79
+ turf's `setRecovery(label, fn, { phantom })` is a method on that app's
80
+ legacy compatibility proxy and renders a recovery button on ITS `_onchain_tx`
81
+ card; the engine specimen's error state is `blocks/_card_header` plus a plain
82
+ Close, with no recovery affordance. Porting it would have meant designing a
83
+ new block into a shipped partial, so it was dropped rather than shimmed —
84
+ roughly half the ported demos drove that proxy, and none of them reference it
85
+ now (asserted).
86
+
87
+ **Both sections are graded in a browser**, because neither is observable from
88
+ the response bytes: `e2e/style_modal_simulators.spec.js` (7 specs) drives a new
89
+ `/lab/style_modals` lab page, which mounts the shared modal host exactly as a
90
+ consumer layout does — without it `window.ModalAnimations` is undefined, the
91
+ build produces no controls, and "the controls match the registry" would be
92
+ vacuously true at `0 == 0`. Each spec was verified RED against its own defect
93
+ reintroduced, with the lab server restarted between runs. The lane contract
94
+ moves 116 → 123 (`config/e2e_lane.yml`), re-derived with the lister.
95
+
24
96
  ### Changed
25
97
 
26
98
  - **`Gemfile.lock` resolves solana-studio 0.5.7, and a gate now keeps it there.** The lock had sat on **0.5.3 for four patch releases** while BOTH consumers shipped 0.5.7 (turf-monster `~> 0.5.3`, mcritchie-studio `~> 0.5`). Nothing was red and nothing could have been: engine CI installs with `bundler-cache: true`, so it resolves from the lock and never fresh — the drift does not self-correct and never surfaces as flakiness. It matters because `test/views/style_web3_specimens_test.rb` exists to prove "the style guide renders the REAL gem cards" and reads them off whatever the LOCK resolved; four versions behind, that guard certifies a card no consumer receives. It still passes — only its MEANING changes. MEASURED on this span, the gem's whole `app/` tree was byte-identical 0.5.3 → 0.5.7 (only `CHANGELOG.md`, `README.md` and `version.rb` differ), so this instance cost nothing, which is exactly why it went four releases unnoticed.
@@ -55,6 +127,73 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
55
127
 
56
128
  ### Fixed
57
129
 
130
+ - **The style guide's page-scoped modal store now resolves animations through
131
+ the LIVE registry, so the guide can no longer disagree with itself.**
132
+ `style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
133
+ while the new simulator builds its controls from `window.ModalAnimations`.
134
+ The two agreed today, which is exactly why this was invisible — but a
135
+ consumer registering a key would have grown a control from the registry that
136
+ the store then resolved back to `pop`: the button said "shake", the card
137
+ popped, and nothing reported the gap. Its `modalAnim` now reads
138
+ `window.ModalAnimations` at CALL time with the same late-binding guard the
139
+ shared host uses (unknown keys and a gutted registry still fall back to
140
+ `pop`, so a miss can never throw on `.ms` and strand a modal open); the local
141
+ table remains only as the fallback for a guide rendered without the host.
142
+ MEASURED in a browser both ways: registering a new key at runtime surfaces a
143
+ control AND plays it, and with this fix reverted the same key surfaces a
144
+ control that plays `modal-card-mount`.
145
+
146
+ - **Onboarding no longer truncates a surname to fit a first name's cap.**
147
+ `Studio::OnboardingController#first_name` measured the WHOLE typed answer with
148
+ `Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
149
+ name and people type a full one, so "Bartholomew Fitzwilliam
150
+ Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
151
+ back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
152
+ `name` feeds.
153
+
154
+ **ONE CONSTANT WAS ANSWERING TWO QUESTIONS**, which is why neither call site
155
+ looked wrong. `/profile` applies the same number PER FIELD (`normalized_name`,
156
+ once to each of its two inputs); onboarding applied it to first name PLUS
157
+ surname in one string. The fix NAMES the second question rather than
158
+ re-scoping the first: **`Studio::FULL_NAME_MAX_LENGTH`**, the whole-answer
159
+ cap, `(FIRST_NAME_MAX_LENGTH * 2) + 1` = **81**. It is DERIVED, not picked —
160
+ first(40) + a space + last(40) is the longest answer whose two halves BOTH
161
+ still fit the per-field cap, so onboarding can never accept a name `/profile`
162
+ would silently shorten on the next save, which is the drift the shared
163
+ constant existed to prevent. `FIRST_NAME_MAX_LENGTH` keeps its value and its
164
+ meaning, and `test/integration/profile_name_field_caps_test.rb` now proves
165
+ that by sending BOTH fields over-long in one request and reading two 40s back
166
+ — a single-field assertion cannot tell "per field" from "per whole name".
167
+
168
+ **PAST THE CAP IT REFUSES INSTEAD OF TRUNCATING** (422 + `{ error: … }`,
169
+ rendered inline by the modal). `.first(cap)` on a name is not a cap but a
170
+ rewrite: it accepted the answer, stored something nobody typed, and reported
171
+ success — and raising the number alone would only move that cliff one
172
+ character further out. Two independent rules, because the whole answer becomes
173
+ `name` while each half becomes its own column: the answer must fit the
174
+ whole-answer cap, and each derived half must fit the per-field cap (a single
175
+ 60-character word passes the first and would still land a `first_name`
176
+ `/profile` shortens). The onboarding input's `maxlength` default was a LITERAL
177
+ `40` — a third copy of the per-field number, on the one field that receives a
178
+ whole name — and now reads `FULL_NAME_MAX_LENGTH`, so the browser bound and
179
+ the server bound cannot drift.
180
+
181
+ **NEITHER CAP IS A COLUMN CEILING.** Measured 2026-09-06 across all six
182
+ consumer databases (mcritchie-studio, turf-monster, mcritchie-industries;
183
+ production and QA): `name`, `first_name` and `last_name` are unbounded
184
+ `character varying` — `character_maximum_length` NULL, no CHECK constraint, no
185
+ model length validation. Raising the bound cannot trade a silent truncation
186
+ for a raised error at any value, so this is a product decision start to
187
+ finish.
188
+
189
+ **NO DATA REPAIR SHIPS.** Re-measured for THIS defect on the same six
190
+ databases: **zero rows** carry a `name`, `first_name` or `last_name` at or
191
+ over 40 characters, and the longest `name` in the fleet is 27 (control:
192
+ 5 / 6 / 46 / 15 / 4 / 4 users scanned, `max(length(name))` non-null in every
193
+ one). Nothing was truncated in production, so there is nothing to repair — and
194
+ a row whose halves disagree with its `name` because of a `/profile` edit must
195
+ never be auto-repaired anyway.
196
+
58
197
  - **Onboarding no longer stores a two-word answer as the whole first name.**
59
198
  `Studio::OnboardingController#first_name` writes with `update_columns`, which
60
199
  skips callbacks — so the host's `before_save :set_name_parts` never ran and
@@ -72,10 +211,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
72
211
  `test/integration/onboarding_name_parts_test.rb` carries that as a live
73
212
  control (`update!` moves `pat-pat@example.com` → `ada-lovelace-pat@…`), not as
74
213
  a claim. `last_name` is written only where the column exists: the engine's own
75
- standard-columns migration does not carry it, so mcritchie-industries,
76
- moms-app and acquisition-studio would otherwise have taken a
77
- `MissingAttributeError` 500 on every hyphen-free two-word answer
78
- (`test/integration/onboarding_thin_host_test.rb`).
214
+ standard-columns migration does not carry it, so a host that opts into these
215
+ routes before it runs that column would otherwise take a
216
+ `MissingAttributeError` 500 on every two-word answer
217
+ (`test/integration/onboarding_thin_host_test.rb`). **Corrected 2026-09-06:**
218
+ an earlier version of this entry named mcritchie-industries, moms-app and
219
+ acquisition-studio as apps that would have taken that 500. They carry no
220
+ `last_name`, but they do not mount these endpoints at all —
221
+ `Studio.draw_onboarding_routes` defaults to false and only mcritchie-studio
222
+ and turf-monster set it true, and both of those HAVE the column. The guard is
223
+ a standing one for the next host to opt in, not a live save.
79
224
  NO DATA REPAIR SHIPS: measured 2026-09-05 across both production databases
80
225
  (mcritchie-studio 5 users, turf-monster 43) and both QA ones, **zero rows**
81
226
  carry a space in `first_name`. Repairing rows whose halves merely disagree
@@ -23,22 +23,40 @@ module Studio
23
23
  # Both actions are for the freshly signed-in user, so authentication is the
24
24
  # host's default require_authentication — no skip_before_action here.
25
25
 
26
- # The shared cap (Studio::FIRST_NAME_MAX_LENGTH). Kept under the old name
27
- # here because it is referenced from outside this class; what changed is that
28
- # it is no longer a SECOND definition of the same number. /profile writes the
29
- # same column and now reads the same constant.
26
+ # The PER-FIELD cap (Studio::FIRST_NAME_MAX_LENGTH) what one derived half
27
+ # may be. Kept under the old name here because it is referenced from outside
28
+ # this class; what changed is that it is no longer a SECOND definition of
29
+ # the same number. /profile writes the same columns and reads the same
30
+ # constant.
30
31
  MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
31
32
 
33
+ # The WHOLE-ANSWER cap (Studio::FULL_NAME_MAX_LENGTH) — what this endpoint
34
+ # actually bounds. It is a different question from the one above and now has
35
+ # a different name: the field asks for a name and people type a full one, so
36
+ # the string arriving here is first name PLUS surname, and measuring it with
37
+ # the per-field number stored 40 characters of a 41-character name and
38
+ # handed the account back a misspelled surname.
39
+ MAX_FULL_NAME = Studio::FULL_NAME_MAX_LENGTH
40
+
32
41
  # POST /onboarding/first_name
33
42
  #
34
43
  # Writes users.first_name, and backfills `name` when it is blank so the
35
44
  # display-name chain has something better than an email prefix to show.
36
45
  def first_name
37
- value = params[:first_name].to_s.strip.gsub(/\s+/, " ").first(MAX_FIRST_NAME)
46
+ value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
38
47
 
39
48
  if value.blank?
40
- return render json: { ok: false, error: "Enter your first name, or skip for now." },
41
- status: :unprocessable_entity
49
+ return refuse("Enter your first name, or skip for now.")
50
+ end
51
+
52
+ # DERIVE FIRST, THEN GUARD, THEN WRITE. The halves are what the length
53
+ # rules are about, so they are computed once and both the guard and the
54
+ # write read the same hash — a guard measuring a second derivation could
55
+ # pass over values the write then stored.
56
+ attrs = name_columns(value)
57
+
58
+ if (message = length_refusal(value, attrs))
59
+ return refuse(message)
42
60
  end
43
61
 
44
62
  rescue_and_log(target: current_user) do
@@ -63,7 +81,7 @@ module Studio
63
81
  # never self-healed, because set_name_parts is gated on `name_changed?`
64
82
  # and no later save sees a name change. So the derivation comes to the
65
83
  # writer instead: Studio::NameParts is the same rule the callback runs.
66
- attrs = name_columns(value)
84
+ # (`attrs` was derived above, so the guard and this write cannot disagree.)
67
85
  attrs[:name] = value if current_user.name.blank?
68
86
  current_user.update_columns(attrs)
69
87
 
@@ -86,6 +104,50 @@ module Studio
86
104
 
87
105
  private
88
106
 
107
+ # The one refusal shape both guards answer with. The modal renders `error`
108
+ # inline and leaves the field filled, so a refusal here is a correction the
109
+ # person can act on rather than a dead end.
110
+ def refuse(message)
111
+ render json: { ok: false, error: message }, status: :unprocessable_entity
112
+ end
113
+
114
+ # WHY THIS REFUSES INSTEAD OF TRUNCATING, which is the product decision this
115
+ # endpoint used to make silently and badly.
116
+ #
117
+ # `.first(cap)` on a name is not a cap, it is a rewrite: it accepted the
118
+ # answer, stored something the person never typed, and reported success.
119
+ # "Bartholomew Fitzwilliam Montgomery-Smythe" came back as
120
+ # "…Montgomery-Smyth" — plausible enough to miss, which is the worst kind of
121
+ # wrong for a name. Raising the number alone only moves that cliff one
122
+ # character further out; a bound that rewrites its input is the defect at
123
+ # any value. So the bound goes up (MAX_FULL_NAME now fits a real full name)
124
+ # AND anything past it is refused with a sentence saying so.
125
+ #
126
+ # BOTH LENGTHS ARE CHECKED, and they are independent rather than redundant.
127
+ # The whole answer becomes `name`; each half becomes its own column that
128
+ # /profile caps separately. A single 60-character word passes the first rule
129
+ # and would still land a `first_name` /profile silently shortens on the next
130
+ # save — the very drift the two constants exist to prevent — so the halves
131
+ # are measured too.
132
+ #
133
+ # UNREACHABLE FROM THE MODAL, ON PURPOSE. The partial's input carries the
134
+ # same MAX_FULL_NAME as its maxlength, so the browser stops a typist first.
135
+ # This is the server being the real bound, for a direct POST or for a host
136
+ # that passes a larger `max_length` local.
137
+ #
138
+ # Returns the message to refuse with, or nil to proceed.
139
+ def length_refusal(value, attrs)
140
+ if value.length > MAX_FULL_NAME
141
+ return "Please shorten that to #{MAX_FULL_NAME} characters or fewer."
142
+ end
143
+
144
+ if attrs.values.compact.any? { |part| part.length > MAX_FIRST_NAME }
145
+ return "Please keep each name to #{MAX_FIRST_NAME} characters or fewer."
146
+ end
147
+
148
+ nil
149
+ end
150
+
89
151
  # The name halves to write, derived exactly as the host's set_name_parts
90
152
  # would derive them from the same string.
91
153
  #
@@ -12,10 +12,23 @@
12
12
  Locals (all optional, defaults via local_assigns.fetch):
13
13
  submit_path — POST target for the name (default "/onboarding/first_name")
14
14
  skip_path — POST target for the skip (default "/onboarding/skip_first_name")
15
+ required — hide the SKIP affordances (default false, which keeps today's
16
+ skippable card). The × then merely CLOSES and is labelled
17
+ Close, and the "Skip for now" button is not rendered. For a
18
+ host that GATES something on the name: offering to skip would
19
+ promise a way past a wall that does not move. Closing stays
20
+ available — a required step abandons the flow, it never traps
21
+ the user in it.
15
22
  heading — (default "What should we call you?")
16
- subtext — the one-line why (default speaks about emails)
23
+ subtext — the one-line why (default speaks about emails; the DEFAULT
24
+ follows `required`, because a card that cannot be skipped is
25
+ talking to a different audience — a host that passes its own
26
+ subtext still wins)
17
27
  placeholder — (default "Alex")
18
- max_length — (default 40; the server stays the real bound)
28
+ max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
29
+ real bound, and it REFUSES past that rather than truncating,
30
+ so a host raising this makes the endpoint answer 422 rather
31
+ than store a name nobody typed)
19
32
  progress — [current, total] to render the segmented pill, or nil for none
20
33
  modal_store — Alpine store name (default "modals"; the living style guide
21
34
  mounts its own page-scoped host and passes "dsModals")
@@ -37,15 +50,27 @@
37
50
  <%
38
51
  submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
39
52
  skip_path = local_assigns.fetch(:skip_path, "/onboarding/skip_first_name")
53
+ required = local_assigns.fetch(:required, false)
40
54
  heading = local_assigns.fetch(:heading, "What should we call you?")
41
- subtext = local_assigns.fetch(:subtext,
42
- "Just your first name we use it to address you in emails.")
55
+ default_subtext = if required
56
+ "One last thing — just your first name, so we can address you in emails."
57
+ else
58
+ "Just your first name — we use it to address you in emails."
59
+ end
60
+ subtext = local_assigns.fetch(:subtext, default_subtext)
43
61
  placeholder = local_assigns.fetch(:placeholder, "Alex")
44
- max_length = local_assigns.fetch(:max_length, 40)
62
+ max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
45
63
  progress = local_assigns.fetch(:progress, nil)
46
64
  modal_store = local_assigns.fetch(:modal_store, "modals")
47
65
  done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
48
66
  field_id = local_assigns.fetch(:id, "onboarding-first-name")
67
+ # The × mirrors the skip affordance it sits beside: it SKIPS while the step is
68
+ # skippable, and merely CLOSES once it is required. Resolved here rather than in
69
+ # an Alpine expression because `required` is known at render time — and a skip
70
+ # button hidden with x-show is still in the DOM, and still clickable, for as
71
+ # long as Alpine has not mounted.
72
+ dismiss_action = required ? "$store.#{modal_store}.close()" : "skip()"
73
+ dismiss_label = required ? "Close" : "Skip"
49
74
  %>
50
75
  <div x-data="{
51
76
  get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
@@ -98,10 +123,15 @@
98
123
  <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1"><%= heading %></h3>
99
124
  <%# The × SKIPS rather than merely closing: this lands seconds after signup,
100
125
  and a close that silently abandons the chain is how a host ends up with a
101
- step nobody can answer again. %>
102
- <button @click="skip()"
126
+ step nobody can answer again.
127
+
128
+ When the host passes required: true it merely CLOSES, and says so. That
129
+ step is gating something the host will not let through without a name, so
130
+ recording a skip would promise a way past a wall that does not move.
131
+ Closing still works — it abandons the flow rather than slipping past it. %>
132
+ <button @click="<%= dismiss_action %>"
103
133
  class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
104
- aria-label="Skip">&times;</button>
134
+ aria-label="<%= dismiss_label %>">&times;</button>
105
135
  </div>
106
136
  <% if progress %>
107
137
  <%= render "studio/modals/blocks/progress_pill", current: progress.first, total: progress.last %>
@@ -131,8 +161,13 @@
131
161
  </button>
132
162
  </form>
133
163
 
134
- <button type="button" @click="skip()" :disabled="submitting"
135
- class="mt-3 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
136
- Skip for now
137
- </button>
164
+ <%# Gone entirely when required — see the × above for why offering it there
165
+ would promise a way past a wall that does not move. Omitted rather than
166
+ hidden, for the same reason the × is resolved server-side. %>
167
+ <% unless required %>
168
+ <button type="button" @click="skip()" :disabled="submitting"
169
+ class="mt-3 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
170
+ Skip for now
171
+ </button>
172
+ <% end %>
138
173
  </div>
@@ -211,7 +211,20 @@
211
211
  // slide uses the modal-card-swap-* classes regardless of these keys.
212
212
  var CLOSE_ANIM_MS = 220;
213
213
  var SWAP_IN_MS = 220;
214
- var Anim = {
214
+
215
+ // THE LIVE REGISTRY IS window.ModalAnimations, NOT THIS TABLE. This is a
216
+ // fallback only, for the case where the guide renders without the shared
217
+ // host (studio/modals/_host) that publishes the registry — both consumer
218
+ // layouts mount it, so in practice the lookup below always finds it.
219
+ //
220
+ // It used to be a hard-coded local copy, and that made the Enter/Leave
221
+ // simulator below a LIAR: the simulator builds its controls from the live
222
+ // registry, so a newly registered animation grew a button here while the
223
+ // store silently resolved the unknown key back to 'pop'. The control said
224
+ // "shake", the card popped, and nothing reported the gap. Reading the
225
+ // registry at CALL time is what makes a registered animation actually
226
+ // playable on this page.
227
+ var ANIM_FALLBACK = {
215
228
  enter: {
216
229
  pop: { cls: 'modal-card-mount', ms: 320 },
217
230
  shake: { cls: 'modal-card-shake-in', ms: 600 },
@@ -222,9 +235,13 @@
222
235
  slide: { cls: 'modal-card-swap-out', ms: 220 }
223
236
  }
224
237
  };
238
+ // Same late-binding guard, and for the same reason, as the host's own
239
+ // modalAnim: never return undefined. A miss would throw on .ms in close()
240
+ // and strand the modal open, so an unknown key falls back to 'pop'.
225
241
  function modalAnim(channel, key) {
226
- var table = Anim[channel] || {};
227
- return table[key] || table.pop;
242
+ var table = (window.ModalAnimations && window.ModalAnimations[channel]) ||
243
+ ANIM_FALLBACK[channel];
244
+ return table[key] || table.pop || ANIM_FALLBACK[channel].pop;
228
245
  }
229
246
 
230
247
  Alpine.store('dsModals', {
@@ -766,7 +783,7 @@
766
783
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
767
784
  <%= render layout: "style/modal_specimen", locals: {
768
785
  label: "First name",
769
- reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
786
+ reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain. A host that GATES on the name passes required: true, which drops BOTH skip affordances and leaves the × merely closing — this specimen shows the default, skippable card),
770
787
  open_expr: "$store.dsModals.open('onboarding-first-name')",
771
788
  glow_when: ds_glow.call("onboarding-first-name")
772
789
  } do %>
@@ -1914,4 +1931,355 @@
1914
1931
  </div>
1915
1932
  </section>
1916
1933
 
1934
+ <%# ===================================================================== %>
1935
+ <%# 8. MOTION REGISTRY (the enter / leave animation simulator) %>
1936
+ <%# ===================================================================== %>
1937
+ <section id="modals-motion-registry" class="space-y-5">
1938
+ <div class="space-y-1">
1939
+ <h3 class="text-xl font-bold text-heading">Enter &amp; leave animations</h3>
1940
+ <p class="text-muted text-sm">
1941
+ The per-modal entrance/exit registry &mdash;
1942
+ <code class="font-mono text-2xs">window.ModalAnimations</code>, published by
1943
+ <code class="font-mono text-2xs">studio/modals/_host</code>. Any modal opens with
1944
+ <code class="font-mono text-2xs">open(id, { enterAnim, exitAnim })</code>; omit either
1945
+ and it uses <code class="font-mono text-2xs">pop</code>. Every control below is
1946
+ <strong class="text-heading font-semibold">generated from the live registry</strong>,
1947
+ so registering a new animation surfaces it here with no edit to this page. Demo card:
1948
+ <code class="font-mono text-2xs">email-change-pending</code>.
1949
+ </p>
1950
+ </div>
1951
+
1952
+ <div class="grid gap-4 sm:grid-cols-2">
1953
+ <%# One control per registered ENTER key, built by the script below. %>
1954
+ <div class="card p-5 space-y-3">
1955
+ <h4 class="text-heading font-semibold text-sm">
1956
+ Enter <span class="text-muted text-xs font-normal">&mdash; watch it arrive</span>
1957
+ </h4>
1958
+ <p class="text-secondary text-xs">
1959
+ Opens the demo card with that entrance. Dismiss with Escape, click-outside, or Close.
1960
+ </p>
1961
+ <div id="modal-anim-enter-buttons" class="flex flex-wrap gap-2"></div>
1962
+ </div>
1963
+
1964
+ <%# One control per registered EXIT key: opens with a plain entrance, then
1965
+ auto-closes a beat later so the exit keyframe is what you watch. %>
1966
+ <div class="card p-5 space-y-3">
1967
+ <h4 class="text-heading font-semibold text-sm">
1968
+ Leave <span class="text-muted text-xs font-normal">&mdash; watch it dismiss</span>
1969
+ </h4>
1970
+ <p class="text-secondary text-xs">
1971
+ Opens with a plain entrance, then closes itself so the exit keyframe is the star.
1972
+ </p>
1973
+ <div id="modal-anim-exit-buttons" class="flex flex-wrap gap-2"></div>
1974
+ </div>
1975
+
1976
+ <%# The full matrix: any entrance crossed with any exit. %>
1977
+ <div class="card p-5 space-y-3 sm:col-span-2">
1978
+ <h4 class="text-heading font-semibold text-sm">Combine</h4>
1979
+ <p class="text-secondary text-xs">Pick any entrance and exit, then run the round trip.</p>
1980
+ <div class="flex flex-wrap items-end gap-3">
1981
+ <label class="text-xs text-secondary">Enter
1982
+ <select id="modal-anim-enter-select"
1983
+ class="block mt-1 text-sm bg-inset border border-subtle rounded px-2 py-1 text-heading"></select>
1984
+ </label>
1985
+ <label class="text-xs text-secondary">Leave
1986
+ <select id="modal-anim-exit-select"
1987
+ class="block mt-1 text-sm bg-inset border border-subtle rounded px-2 py-1 text-heading"></select>
1988
+ </label>
1989
+ <button type="button" class="btn btn-primary btn-sm"
1990
+ onclick="dsModalDemos.animOpenSelected()">Open</button>
1991
+ <button type="button" class="btn btn-outline btn-sm"
1992
+ onclick="dsModalDemos.animRoundTripSelected()">Open, then auto-close</button>
1993
+ <button type="button" class="btn btn-outline btn-sm"
1994
+ @click="$store.dsModals.close()">Close</button>
1995
+ </div>
1996
+ </div>
1997
+ </div>
1998
+ </section>
1999
+
2000
+ <%# ===================================================================== %>
2001
+ <%# 9. STACK MECHANICS (dismissibility, hold-at-least, LIFO stacking) %>
2002
+ <%# ===================================================================== %>
2003
+ <section id="modals-stack-mechanics" class="space-y-5">
2004
+ <div class="space-y-1">
2005
+ <h3 class="text-xl font-bold text-heading">Stack behaviour</h3>
2006
+ <p class="text-muted text-sm">
2007
+ The engine modal-stack mechanics, poked at directly on
2008
+ <code class="font-mono text-2xs">$store.dsModals</code>: the on-chain TX card states,
2009
+ dismissibility, the minimum-visible-duration floor, and LIFO stacking.
2010
+ </p>
2011
+ </div>
2012
+
2013
+ <div class="grid gap-4 sm:grid-cols-2">
2014
+ <%# The three states of style/modals/_onchain_tx. The two static buttons open
2015
+ the card AT a state; the two timed buttons drive the transition through
2016
+ the store's advance(), which is the in-flow step primitive. %>
2017
+ <div class="card p-5 space-y-3">
2018
+ <h4 class="text-heading font-semibold text-sm">On-chain TX states</h4>
2019
+ <p class="text-secondary text-xs">
2020
+ Engine <code class="font-mono text-2xs">blocks/_card_header</code> and
2021
+ <code class="font-mono text-2xs">blocks/_onchain_success</code>. The timed pair
2022
+ transitions a live card with
2023
+ <code class="font-mono text-2xs">advance()</code>.
2024
+ </p>
2025
+ <div class="flex flex-wrap gap-2">
2026
+ <button type="button" class="btn btn-outline btn-sm"
2027
+ onclick="dsModalDemos.processing()">Processing</button>
2028
+ <button type="button" class="btn btn-outline btn-sm"
2029
+ onclick="dsModalDemos.success()">Success</button>
2030
+ <button type="button" class="btn btn-outline btn-sm"
2031
+ onclick="dsModalDemos.error()">Error</button>
2032
+ <button type="button" class="btn btn-primary btn-sm"
2033
+ onclick="dsModalDemos.processThenSuccess()">Processing to success (3s)</button>
2034
+ <button type="button" class="btn btn-primary btn-sm"
2035
+ onclick="dsModalDemos.processThenError()">Processing to error (3s)</button>
2036
+ </div>
2037
+ </div>
2038
+
2039
+ <%# dismissible: false is what a pending transaction sets so a stray Escape
2040
+ cannot orphan an in-flight signature. %>
2041
+ <div class="card p-5 space-y-3">
2042
+ <h4 class="text-heading font-semibold text-sm">Dismissibility</h4>
2043
+ <p class="text-secondary text-xs">
2044
+ A processing card passes <code class="font-mono text-2xs">dismissible: false</code>, so
2045
+ Escape and click-outside are both suppressed until it resolves.
2046
+ </p>
2047
+ <div class="flex flex-wrap gap-2">
2048
+ <button type="button" class="btn btn-outline btn-sm"
2049
+ onclick="dsModalDemos.processing()">Try Escape (locked)</button>
2050
+ <button type="button" class="btn btn-outline btn-sm"
2051
+ onclick="dsModalDemos.dismissible()">Dismissible processing</button>
2052
+ </div>
2053
+ </div>
2054
+
2055
+ <%# The anti-flicker floor from studio/modals/_load_convention. %>
2056
+ <div class="card p-5 space-y-3">
2057
+ <h4 class="text-heading font-semibold text-sm">Hold at least</h4>
2058
+ <p class="text-secondary text-xs">
2059
+ <code class="font-mono text-2xs">StudioModals.holdAtLeast(ms)</code> keeps a fast
2060
+ operation from flashing the spinner. Run both and watch the difference.
2061
+ </p>
2062
+ <div class="flex flex-wrap gap-2">
2063
+ <button type="button" class="btn btn-primary btn-sm"
2064
+ onclick="dsModalDemos.fastWithHold()">Fast op, hold 1500ms</button>
2065
+ <button type="button" class="btn btn-outline btn-sm"
2066
+ onclick="dsModalDemos.fastNoHold()">Fast op, no hold (flashes)</button>
2067
+ </div>
2068
+ </div>
2069
+
2070
+ <%# LIFO: open pushes, close pops back to what was underneath. %>
2071
+ <div class="card p-5 space-y-3">
2072
+ <h4 class="text-heading font-semibold text-sm">Stacking</h4>
2073
+ <p class="text-secondary text-xs">
2074
+ LIFO &mdash; a second <code class="font-mono text-2xs">open()</code> pushes the first
2075
+ card behind it, and <code class="font-mono text-2xs">close()</code> pops back to it.
2076
+ </p>
2077
+ <div class="flex flex-wrap gap-2">
2078
+ <button type="button" class="btn btn-primary btn-sm"
2079
+ onclick="dsModalDemos.stackTwo()">Open two</button>
2080
+ <button type="button" class="btn btn-outline btn-sm"
2081
+ @click="$store.dsModals.closeAll()">closeAll()</button>
2082
+ </div>
2083
+ </div>
2084
+ </div>
2085
+ </section>
2086
+
2087
+ <%# ---- Demo drivers for the two sections above -------------------------
2088
+ Deliberately NOT an Alpine component: these are multi-step sequences with
2089
+ timers, and an x-data scope would be cloned per specimen card. One global
2090
+ namespace, defined after the markup it drives so the control containers
2091
+ exist when the registry build runs.
2092
+
2093
+ THE STORE IS dsModals. turf-monster's version of this page drove roughly
2094
+ half of these through its legacy solana-modal compatibility proxy, a store
2095
+ this engine does not ship; every demo here goes through the page-scoped
2096
+ store and the onchain-tx specimen directly instead. The exact spelling of
2097
+ that legacy store name is deliberately absent from this file, because
2098
+ style_guide_modal_simulator_test scans the source for it. %>
2099
+ <script>
2100
+ window.dsModalDemos = (function () {
2101
+ var SAMPLE_TX = '5xTrDemoSignature1234567890abcXYZ';
2102
+
2103
+ function store() { return Alpine.store('dsModals'); }
2104
+
2105
+ // Open the onchain-tx specimen. The defaults mirror a real pending
2106
+ // transaction, INCLUDING dismissible: false — the demos that want an
2107
+ // escapable card override it explicitly.
2108
+ function openTx(props) {
2109
+ store().open('onchain-tx', Object.assign({
2110
+ state: 'processing',
2111
+ title: 'Submitting entry',
2112
+ message: 'Awaiting wallet signature',
2113
+ dismissible: false
2114
+ }, props || {}));
2115
+ }
2116
+
2117
+ // In-flow state change on the card that is already open. advance() patches
2118
+ // props WITHOUT replacing the stack entry, so the specimen's x-data scope
2119
+ // survives the transition. Guarded to a still-open onchain-tx card: a timer
2120
+ // that fires after the user closed it must do nothing rather than patch
2121
+ // whatever card happens to be on top now.
2122
+ function advanceTx(patch) {
2123
+ var cur = store().current();
2124
+ if (!cur || cur.id !== 'onchain-tx' || cur._closing) return;
2125
+ store().advance(patch);
2126
+ }
2127
+
2128
+ var SUCCESS = {
2129
+ state: 'success',
2130
+ txSignature: SAMPLE_TX,
2131
+ successTitle: 'Entry confirmed',
2132
+ successSubtitle: 'Your entry is confirmed on-chain.',
2133
+ ctaLabel: 'View wallet',
2134
+ ctaHref: null,
2135
+ dismissible: true
2136
+ };
2137
+ var FAILURE = {
2138
+ state: 'error',
2139
+ title: 'Submission failed',
2140
+ errorMessage: 'Transaction simulation failed: insufficient SOL for rent.',
2141
+ dismissible: true
2142
+ };
2143
+
2144
+ // === Enter / leave simulator =====================================
2145
+ // The demo vehicle is the always-registered email-change-pending card, so
2146
+ // only the motion varies between runs.
2147
+ var ANIM_DEMO_ID = 'email-change-pending';
2148
+ var ANIM_DEMO_PROPS = { currentEmail: 'you@example.com', newEmail: 'new@example.com' };
2149
+
2150
+ function animOpen(enterAnim, exitAnim) {
2151
+ store().open(ANIM_DEMO_ID, Object.assign({}, ANIM_DEMO_PROPS, {
2152
+ enterAnim: enterAnim, exitAnim: exitAnim
2153
+ }));
2154
+ }
2155
+
2156
+ // Open, let the entrance settle, then close so the chosen EXIT keyframe is
2157
+ // what you watch. The hold is the entrance's own registry duration plus a
2158
+ // beat, read from the live registry rather than assumed.
2159
+ function animRoundTrip(enterAnim, exitAnim) {
2160
+ animOpen(enterAnim, exitAnim);
2161
+ var reg = window.ModalAnimations || { enter: {}, exit: {} };
2162
+ var enterMs = ((reg.enter[enterAnim] || {}).ms) || 320;
2163
+ setTimeout(function () { store().close(); }, enterMs + 700);
2164
+ }
2165
+
2166
+ function el(id) { return document.getElementById(id); }
2167
+
2168
+ // Build the quick buttons and the dropdown options FROM THE LIVE REGISTRY.
2169
+ // This is the property the section exists to demonstrate: a newly
2170
+ // registered animation key grows a control here with no edit to this file.
2171
+ // Idempotent — it clears each container first, so re-running on a Turbo
2172
+ // visit cannot double the controls.
2173
+ function buildAnimControls() {
2174
+ var reg = window.ModalAnimations || { enter: {}, exit: {} };
2175
+
2176
+ var label = function (key) {
2177
+ return key.charAt(0).toUpperCase() + key.slice(1) + (key === 'pop' ? ' (default)' : '');
2178
+ };
2179
+ var addOption = function (sel, key) {
2180
+ if (!sel) return;
2181
+ var o = document.createElement('option');
2182
+ o.value = key;
2183
+ o.textContent = label(key);
2184
+ if (key === 'pop') o.selected = true;
2185
+ sel.appendChild(o);
2186
+ };
2187
+ var addButton = function (container, key, onClick, suffix) {
2188
+ if (!container) return;
2189
+ var b = document.createElement('button');
2190
+ b.type = 'button';
2191
+ b.className = 'btn btn-outline btn-sm';
2192
+ b.textContent = label(key) + (suffix || '');
2193
+ b.onclick = onClick;
2194
+ container.appendChild(b);
2195
+ };
2196
+
2197
+ var enterBtns = el('modal-anim-enter-buttons');
2198
+ var exitBtns = el('modal-anim-exit-buttons');
2199
+ var enterSel = el('modal-anim-enter-select');
2200
+ var exitSel = el('modal-anim-exit-select');
2201
+
2202
+ [enterBtns, exitBtns, enterSel, exitSel].forEach(function (node) {
2203
+ if (node) node.innerHTML = '';
2204
+ });
2205
+
2206
+ Object.keys(reg.enter || {}).forEach(function (key) {
2207
+ addButton(enterBtns, key, function () { animOpen(key, 'pop'); }, ' ↗');
2208
+ addOption(enterSel, key);
2209
+ });
2210
+ Object.keys(reg.exit || {}).forEach(function (key) {
2211
+ addButton(exitBtns, key, function () { animRoundTrip('pop', key); });
2212
+ addOption(exitSel, key);
2213
+ });
2214
+ }
2215
+
2216
+ // WHEN TO BUILD. window.ModalAnimations is published by the shared host's
2217
+ // inline script, which a host layout may render AFTER this one — so the
2218
+ // build waits for the rest of the body either way:
2219
+ // full load — DOMContentLoaded, the host script has run by then;
2220
+ // Turbo visit — DOMContentLoaded already fired and never fires again, so
2221
+ // fall through to a macrotask, which runs after the body
2222
+ // swap finishes inserting its scripts.
2223
+ // turbo:load covers the remaining visits; the build is idempotent, so the
2224
+ // overlap between these paths is redundant work and nothing worse.
2225
+ if (document.readyState === 'loading') {
2226
+ document.addEventListener('DOMContentLoaded', buildAnimControls);
2227
+ } else {
2228
+ setTimeout(buildAnimControls, 0);
2229
+ }
2230
+ if (!window.__dsAnimControlsBound) {
2231
+ window.__dsAnimControlsBound = true;
2232
+ document.addEventListener('turbo:load', buildAnimControls);
2233
+ }
2234
+
2235
+ return {
2236
+ // Stack behaviour
2237
+ processing: function () { openTx({}); },
2238
+ success: function () { openTx(SUCCESS); },
2239
+ error: function () { openTx(FAILURE); },
2240
+ processThenSuccess: function () {
2241
+ openTx({ message: 'Awaiting RPC confirmation' });
2242
+ setTimeout(function () { advanceTx(SUCCESS); }, 3000);
2243
+ },
2244
+ processThenError: function () {
2245
+ openTx({ message: 'Awaiting RPC confirmation' });
2246
+ setTimeout(function () { advanceTx(FAILURE); }, 3000);
2247
+ },
2248
+ dismissible: function () {
2249
+ openTx({
2250
+ dismissible: true,
2251
+ title: 'Dismissible processing',
2252
+ message: 'Escape and click-outside both close this one.'
2253
+ });
2254
+ },
2255
+ fastWithHold: function () {
2256
+ openTx({ title: 'Fast operation', message: 'Holding the spinner for at least 1500ms' });
2257
+ window.StudioModals.holdAtLeast(1500).then(function () { advanceTx(SUCCESS); });
2258
+ },
2259
+ fastNoHold: function () {
2260
+ openTx({ title: 'Fast operation', message: 'No hold, so this flashes past' });
2261
+ setTimeout(function () { advanceTx(SUCCESS); }, 50);
2262
+ },
2263
+ stackTwo: function () {
2264
+ openTx({
2265
+ dismissible: true,
2266
+ title: 'Bottom of the stack',
2267
+ message: 'A second card is about to push in front of this one.'
2268
+ });
2269
+ setTimeout(function () {
2270
+ store().open(ANIM_DEMO_ID, ANIM_DEMO_PROPS);
2271
+ }, 700);
2272
+ },
2273
+
2274
+ // Enter / leave simulator
2275
+ animOpenSelected: function () {
2276
+ animOpen(el('modal-anim-enter-select').value, el('modal-anim-exit-select').value);
2277
+ },
2278
+ animRoundTripSelected: function () {
2279
+ animRoundTrip(el('modal-anim-enter-select').value, el('modal-anim-exit-select').value);
2280
+ }
2281
+ };
2282
+ })();
2283
+ </script>
2284
+
1917
2285
  </section>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.69.4"
2
+ VERSION = "0.70.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -407,17 +407,46 @@ module Studio
407
407
  # later session may ask again. That is the whole reason this is not a column.
408
408
  FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
409
409
 
410
- # How long a first name may be. ONE constant because users.first_name is
411
- # written from TWO surfaces the onboarding step (seconds after signup) and
412
- # /profile (any time after) and rendered by a third, the profile form's
413
- # maxlength. Two independently-correct caps that disagreed would let onboarding
414
- # accept a name /profile then refused to save, a bug with no obvious owner.
410
+ # How long ONE name field may be users.first_name, and users.last_name where
411
+ # a host carries it. PER FIELD, which is the only thing this number has ever
412
+ # measured: /profile caps each of the two inputs with it, and the profile
413
+ # form's maxlength renders it. Two independently-correct caps that disagreed
414
+ # would let one surface accept a name another then refused to save, a bug with
415
+ # no obvious owner.
415
416
  #
416
417
  # Keeping it here rather than on either controller also keeps the VIEW off a
417
418
  # controller constant: the form needs the number, and a view reaching into
418
419
  # Studio::ProfilesController to get it would couple the two for no reason.
419
420
  FIRST_NAME_MAX_LENGTH = 40
420
421
 
422
+ # How long a WHOLE typed answer may be — one string holding a full name, which
423
+ # the onboarding step stores as users.name and SPLITS into the two halves
424
+ # above (Studio::NameParts).
425
+ #
426
+ # A SECOND CONSTANT BECAUSE THIS IS A SECOND QUESTION. Onboarding asked the
427
+ # per-field number for a whole-answer bound, and the two readings were both
428
+ # defensible at their own call site: "Bartholomew Fitzwilliam
429
+ # Montgomery-Smythe" is 41 characters, so it stored 40 and handed the account
430
+ # back its own surname misspelled — "Montgomery-Smyth". Nothing was wrong in
431
+ # either controller alone; the number was answering two questions with one
432
+ # value. Giving the whole-answer bound its own name makes that borrow
433
+ # unwriteable rather than merely discouraged.
434
+ #
435
+ # DERIVED, NOT PICKED. It is the longest answer whose two halves BOTH still
436
+ # fit the per-field cap — first(40) + a space + last(40) — so onboarding can
437
+ # never accept a name /profile would later shorten, which is the drift
438
+ # FIRST_NAME_MAX_LENGTH exists to prevent. Raising the per-field cap moves
439
+ # this one with it, by construction.
440
+ #
441
+ # NOT BOUNDED BY THE COLUMN. Measured 2026-09-06 across all six consumer
442
+ # databases (mcritchie-studio, turf-monster and mcritchie-industries, prod and
443
+ # QA): every one of `name`, `first_name` and `last_name` is an unbounded
444
+ # `character varying` — character_maximum_length NULL, no CHECK constraint, no
445
+ # model length validation. So this cap is a product decision start to finish,
446
+ # not a column ceiling, and raising it cannot trade a truncation for a
447
+ # PG::StringDataRightTruncation.
448
+ FULL_NAME_MAX_LENGTH = (FIRST_NAME_MAX_LENGTH * 2) + 1
449
+
421
450
  # The shared rule for "does this account still owe us a first name?" — the one
422
451
  # piece of onboarding logic every app agrees on. Hosts compose it into their own
423
452
  # flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.69.4
4
+ version: 0.70.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails