studio-engine 0.53.1 → 0.54.1

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: 16972aaa58808c367d4c21f31458b67e23e19e56e84198b4bb6721607a6f9d25
4
- data.tar.gz: 1e88013ef4adbda0991bc14df7d9c9e398339ad2201b1d48ce913bc22b212d77
3
+ metadata.gz: b79896d29716fe1408c39d86c9e2d19d4e6dc12dd28749ff85054c29e5ba9a77
4
+ data.tar.gz: 033a9a7e0e4799ef5ed0522e3fb6b6d1652ab4239d7de625add1e24ac81a5386
5
5
  SHA512:
6
- metadata.gz: 9fa4d61d52965e76a67df8646261b59de6eb8729378093c414fa638b2211f020ac64f87ec47a1ddbb7005d69f25fa95699e10e5bb74b6a87137f62d5db25b7ea
7
- data.tar.gz: d71d74679f35845bd0816fb1fade459ceb6ffa08c7e803798026909263f5de988345cec4972b063d8ebb2a1809fa355974e7ca9ef95d4c320538e0566b5213f9
6
+ metadata.gz: dcd13075e85ee744c86ec901f62e749320f916a72c7c04b1530a5715dcdbcec3adcaa39dc1b8236327bc5a2de4238ef1585cf42853444ff92eeae0ff7cfcc702
7
+ data.tar.gz: 6d242c947ec1d582202bb53652091f6ab4ed111cadfa721aab578df1f89f7b8b9a9bdb4bcd9f57b5b8a15ba9d8c9200b2358668ec2058d59f0c210072e93a79a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Fixed
8
+
9
+ - **`.conic-surface` keeps its wash inside its own box.** The wash was an
10
+ OVERSIZED pseudo (`inset: -50%`) spun by a `transform` keyframe, cut back to
11
+ the element by the host's `overflow: hidden`. That clip is not dependable: the
12
+ rotating child is composited, and a `backdrop-filter` in its compositing path
13
+ drops it. The `.surface-glass` specimen — a backdrop-filter child of the wash
14
+ itself — is exactly that path, and on admin/style in Chromium the wash escaped
15
+ as a rotated slab across BOTH conic specimens.
16
+
17
+ The element no longer moves. The pseudo sits at `inset: 0` with
18
+ `border-radius: inherit`, and the sweep animates a registered
19
+ `--conic-surface-angle` inside the gradient — the technique §9b already used.
20
+ Nothing extends past the box, the rounded corners come from the pseudo, and no
21
+ clip has to hold. Same picture, one fewer thing that can fail.
22
+ `overflow: hidden` stays for the host's CONTENT.
23
+
24
+ The trade, stated: a registered-property animation repaints on the main thread
25
+ where a transform rode the compositor. At the default 16s sweep that is cheap,
26
+ and §9b already pays it at 4s.
27
+
28
+ - **`.studio-team-glow` is demoed and documented as the wrapper it is.** Its two
29
+ wedge layers are pseudos at z-index -1/-2, and CSS paints an element's own
30
+ background BEFORE its negative-z descendants — so a background on the glow HOST
31
+ is covered BY the wedges, not the other way round, and the ring becomes a wash
32
+ across the whole card face. What covers the middle is an opaque CHILD, which
33
+ paints later; that is TM's `holo-wrap` / `holo-card` split, and what both real
34
+ consumers already ship.
35
+
36
+ The §9b comment claimed the opposite ("host needs an opaque background"), and
37
+ the Effects specimen followed it, so the style guide showed two washed slabs
38
+ where the selection ring belongs. The specimen, its copyable snippet, and the
39
+ contract comment now all state the same thing. The primitive's geometry is
40
+ unchanged — the consumers tuned against it are untouched.
41
+
42
+ ### Added
43
+
44
+ - **`studio/fields/_date_of_birth`** — the engine's one date-of-birth field,
45
+ three selects (Month / Day / Year). Rendered by
46
+ `studio/modals/blocks/_age_verify` and by `/profile/edit`'s birthday row.
47
+
48
+ **It was already duplicated**, which is the real argument for extracting it:
49
+ those selects lived in the age-verify block, and turf-monster forked a second
50
+ copy into its own `modals/_age_verify`. Two copies of one field is how they
51
+ drift.
52
+
53
+ ### Changed
54
+
55
+ - **`/profile/edit`'s birthday drops the calendar popover** for that shared
56
+ field (operator's call). The calendar shipped **four defects in three days**:
57
+ its day grid used `grid-cols-7`, which exists in no consumer bundle, so the
58
+ weekday letters stacked into one column and it grew to the height of the page;
59
+ a flipped popover positioned from a hardcoded 340px height when the real one
60
+ is ~245px; `place()` re-decided the side on every scroll so it snapped
61
+ mid-scroll; and it came back open after a browser Back.
62
+
63
+ Three selects have none of those failure modes — no position, no flip, no
64
+ popover, no scroll listener. Their utilities are also **proven across the
65
+ fleet**: `grid-cols-3` and `label-upper` are present in all three consumers'
66
+ compiled bundles, which `grid-cols-7` never was.
67
+
68
+ The **no-JavaScript path is unchanged**: the native `<input type="date">` still
69
+ renders under `template x-if`, so only one branch is ever in the DOM.
70
+
71
+ Nine browser specs retired with the popover — all of them about positioning —
72
+ and six replaced them, testing where a select-based field can actually be
73
+ wrong: the day list following the month (30 in April, 29 in a leap February),
74
+ clearing a day the new month lost, refusing a partial date, and a year list
75
+ that stops at this year.
76
+
77
+
7
78
  ### Changed
8
79
 
9
80
  - **Each `/profile` row is its own card.** They were rendered inside one merged
@@ -521,10 +521,22 @@
521
521
  transition, so a consumer can fade the glow in/out (the living style guide
522
522
  slides it between step cards as a modal's step machine advances).
523
523
 
524
- Host needs an opaque background (the ring shows only around the edge) and must
525
- NOT be clipped by an overflow-hidden ancestor near its border.
526
-
527
- Usage: <div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>
524
+ THE HOST IS A BARE WRAPPER, NOT THE CARD. Both wedge layers are pseudo-elements
525
+ at z-index -1 / -2, and CSS paints an element's own background FIRST, then its
526
+ negative-z descendants on top of it (CSS 2.1 Appendix E, steps 1 and 2). So a
527
+ background on the HOST cannot cover the wedges — they wash its whole face, and
528
+ the ring is gone. What covers the middle is an opaque CHILD in front: in-flow
529
+ descendants paint later (step 3), leaving only the thickness-wide band around
530
+ the edge. That is TM's holo-wrap / holo-card split, and it is what both
531
+ consumers ship — the engine's style/_modal_specimen (bare glow wrapper + the
532
+ opaque .card article) and the hub's release-phase-meter (bare host + the bar's
533
+ own fill). Demoing it the other way is what shipped a washed card to
534
+ admin/style; the host also must NOT be clipped by an overflow-hidden ancestor
535
+ near its border.
536
+
537
+ Usage: <div class="studio-team-glow rounded-xl">
538
+ <div class="rounded-xl bg-surface p-5">Selected</div>
539
+ </div>
528
540
  red glow: style="--studio-team-glow-color: #E11D48"
529
541
  Tune: --studio-team-glow-color / -opacity / -speed / -thickness / -bloom
530
542
  ========================================================================== */
@@ -604,12 +616,36 @@
604
616
  11. conic-surface — slow rotating conic-gradient wash (authored fresh)
605
617
  A subtle animated ambient background for hero panels / empty states: a conic
606
618
  gradient sweeps between two themed stops (CTA -> surface by default) on a slow
607
- rotation. The gradient lives on a ::before that rotates, so content kept above
608
- it stays crisp. The element's radius clips the wash; give it one.
619
+ rotation. The gradient lives on a ::before behind the content, so content kept
620
+ above it stays crisp.
621
+
622
+ THE WASH IS CONTAINED BY CONSTRUCTION, NOT BY A CLIP. The first cut rotated an
623
+ OVERSIZED child (`inset: -50%` + `transform: rotate`) and leaned on the host's
624
+ `overflow: hidden` to cut it back to the box. That clip is not dependable: the
625
+ rotating child is composited, and a `backdrop-filter` anywhere in its
626
+ compositing path (its OWN .surface-glass child on the style guide, or an
627
+ unrelated overlay elsewhere on the page) drops the clip — the wash then paints
628
+ outside the box as a rotated slab. Observed on admin/style in Chromium, on both
629
+ conic specimens, with the .surface-glass demo the reliable trigger.
630
+
631
+ So the element never moves. The ::before sits at `inset: 0` with
632
+ `border-radius: inherit`, and the sweep animates a registered @property angle
633
+ inside the gradient — the same technique §9b uses. Nothing extends past the
634
+ box, the rounded corners come from the pseudo itself, and no clip has to hold.
635
+ `overflow: hidden` stays for the host's CONTENT, not for the wash.
636
+
637
+ The cost of the trade, stated: a registered-property animation repaints the
638
+ gradient on the main thread each frame, where a transform rode the compositor.
639
+ At the default 16s sweep that is cheap, and §9b already pays it at 4s.
609
640
 
610
641
  Usage: <div class="conic-surface rounded-2xl p-8"> ...content... </div>
611
642
  Tune: --conic-surface-from / --conic-surface-to / --conic-surface-duration
612
643
  ========================================================================== */
644
+ @property --conic-surface-angle {
645
+ syntax: "<angle>";
646
+ inherits: false;
647
+ initial-value: 0deg;
648
+ }
613
649
  .conic-surface {
614
650
  position: relative;
615
651
  isolation: isolate;
@@ -619,10 +655,11 @@
619
655
  .conic-surface::before {
620
656
  content: "";
621
657
  position: absolute;
622
- inset: -50%;
658
+ inset: 0;
659
+ border-radius: inherit;
623
660
  z-index: -1;
624
661
  background: conic-gradient(
625
- from 0deg,
662
+ from var(--conic-surface-angle),
626
663
  var(--conic-surface-from, var(--color-cta)),
627
664
  var(--conic-surface-to, var(--color-surface)),
628
665
  var(--conic-surface-from, var(--color-cta))
@@ -631,7 +668,7 @@
631
668
  }
632
669
 
633
670
  @keyframes conic-surface-spin {
634
- to { transform: rotate(360deg); }
671
+ to { --conic-surface-angle: 360deg; }
635
672
  }
636
673
 
637
674
  /* =============================================================================
@@ -0,0 +1,80 @@
1
+ <%# A date of birth, as three selects. The engine's one DOB field.
2
+
3
+ Locals (all optional):
4
+ label_month / label_day / label_year — the field labels. Defaults below.
5
+ on_change — Alpine expression run when ANY of the three changes, so a call
6
+ site can recompute whatever it derives. Defaults to the
7
+ age-gate's onMonthChange(), which every current caller has.
8
+
9
+ ONE HOOK FOR ALL THREE, where age_verify used onMonthChange()
10
+ for month/year and `error = ''` for day. That is not a
11
+ behaviour change: onMonthChange() clears an out-of-range day
12
+ and then clears the error, and a day just chosen FROM
13
+ dayOptions is never out of range — so on the day select it
14
+ reduces to exactly `error = ''`. Checked rather than assumed,
15
+ because an extraction that quietly alters a call site is worse
16
+ than no extraction.
17
+
18
+ WHAT THE ENCLOSING ALPINE COMPONENT MUST SUPPLY. This partial is markup
19
+ only; it binds to the component it is rendered inside:
20
+
21
+ month, day, year — x-model targets
22
+ months — [{ n: 1, label: "January" }, …]
23
+ dayOptions — the days valid for the selected month/year
24
+ years — the year range this caller offers
25
+
26
+ EXTRACTED BECAUSE IT WAS ALREADY DUPLICATED: these selects lived in
27
+ studio/modals/blocks/_age_verify, and turf-monster forked a second copy into
28
+ its own modals/_age_verify. Two copies of one field is how they drift.
29
+
30
+ AND BECAUSE IT REPLACED SOMETHING WORSE. /profile/edit used a custom calendar
31
+ popover, which shipped four defects in three days — its grid collapsed in
32
+ every consumer (grid-cols-7 exists in no consumer bundle), a flipped popover
33
+ used a hardcoded height and landed ~95px wrong, place() re-decided the side on
34
+ every scroll so it snapped mid-scroll, and it came back OPEN after a browser
35
+ Back. Three selects have none of those failure modes: no positioning, no
36
+ flip, no popover, no scroll listener.
37
+
38
+ ITS UTILITIES ARE PROVEN, which the calendar's were not. Measured across the
39
+ fleet: grid-cols-3 and label-upper are present in turf-monster,
40
+ mcritchie-studio AND mcritchie-industries' compiled bundles. The engine ships
41
+ a prebuilt bundle, so a utility exists in a consumer only if that consumer
42
+ already emitted it — this field's do. %>
43
+ <%
44
+ label_month = local_assigns.fetch(:label_month, "Month")
45
+ label_day = local_assigns.fetch(:label_day, "Day")
46
+ label_year = local_assigns.fetch(:label_year, "Year")
47
+ on_change = local_assigns.fetch(:on_change, "onMonthChange()")
48
+ %>
49
+ <div class="grid grid-cols-3 gap-2">
50
+ <div>
51
+ <label class="label-upper block mb-1"><%= label_month %></label>
52
+ <select x-model="month" @change="<%= on_change %>"
53
+ class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
54
+ <option value="" disabled><%= label_month %></option>
55
+ <template x-for="m in months" :key="m.n">
56
+ <option :value="m.n" x-text="m.label"></option>
57
+ </template>
58
+ </select>
59
+ </div>
60
+ <div>
61
+ <label class="label-upper block mb-1"><%= label_day %></label>
62
+ <select x-model="day" @change="<%= on_change %>"
63
+ class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
64
+ <option value="" disabled><%= label_day %></option>
65
+ <template x-for="d in dayOptions" :key="d">
66
+ <option :value="d" x-text="d"></option>
67
+ </template>
68
+ </select>
69
+ </div>
70
+ <div>
71
+ <label class="label-upper block mb-1"><%= label_year %></label>
72
+ <select x-model="year" @change="<%= on_change %>"
73
+ class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
74
+ <option value="" disabled><%= label_year %></option>
75
+ <template x-for="y in years" :key="y">
76
+ <option :value="y" x-text="y"></option>
77
+ </template>
78
+ </select>
79
+ </div>
80
+ </div>
@@ -49,35 +49,11 @@
49
49
  <%= intro %>
50
50
  </p>
51
51
 
52
- <%# Month / Day / Year selectors %>
53
- <div class="grid grid-cols-3 gap-2 mb-4">
54
- <div>
55
- <label class="label-upper block mb-1">Month</label>
56
- <select x-model="month" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
57
- <option value="" disabled>Month</option>
58
- <template x-for="m in months" :key="m.n">
59
- <option :value="m.n" x-text="m.label"></option>
60
- </template>
61
- </select>
62
- </div>
63
- <div>
64
- <label class="label-upper block mb-1">Day</label>
65
- <select x-model="day" @change="error = ''" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
66
- <option value="" disabled>Day</option>
67
- <template x-for="d in dayOptions" :key="d">
68
- <option :value="d" x-text="d"></option>
69
- </template>
70
- </select>
71
- </div>
72
- <div>
73
- <label class="label-upper block mb-1">Year</label>
74
- <select x-model="year" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
75
- <option value="" disabled>Year</option>
76
- <template x-for="y in years" :key="y">
77
- <option :value="y" x-text="y"></option>
78
- </template>
79
- </select>
80
- </div>
52
+ <%# Month / Day / Year the engine's one DOB field, shared with the profile
53
+ page's birthday row. It lived here first and turf-monster forked a second
54
+ copy; extracting it is what stops the two drifting. %>
55
+ <div class="mb-4">
56
+ <%= render "studio/fields/date_of_birth" %>
81
57
  </div>
82
58
 
83
59
  <%# Live too-young hint — shown once a full date is picked and it fails the
@@ -1,4 +1,4 @@
1
- <%# Birthday — ONE date over THREE integer columns, entered through a calendar.
1
+ <%# Birthday — THREE SELECTS over three integer columns.
2
2
 
3
3
  Locals: user (required).
4
4
 
@@ -9,26 +9,29 @@
9
9
  (needs month and day, and no year at all). A single date column answers the
10
10
  second one badly.
11
11
 
12
- So the UI joins them and ProfilesController#update splits them again. The row
13
- renders only when the host has all three columns, so nothing here re-checks.
12
+ THE SAME FIELD THE AGE GATE USES (operator's call, 2026-08-15). It renders
13
+ studio/fields/_date_of_birth, which studio/modals/blocks/_age_verify also
14
+ renders — one field, two surfaces. It replaced a custom calendar popover that
15
+ shipped four defects in three days; see the component script for the list.
14
16
 
15
17
  TWO INPUTS, ONE NAME, AND ONLY EVER ONE IN THE DOM. `<template x-if>` is what
16
18
  makes that true rather than a claim: the branch Alpine does not take is never
17
19
  instantiated, so the form can never carry two `profile[birthday]` fields and
18
20
  the server can never receive the wrong one. Toggling with x-show would leave
19
21
  BOTH present and submit the hidden one's value too — the last duplicate wins
20
- in Rack's params, which is precisely the bug this shape avoids.
22
+ in Rack's params.
21
23
 
22
- no JS — the native `<input type="date">`. It is not a downgrade; it is the
24
+ no JS — the native `<input type="date">`. Not a downgrade: it is the
23
25
  control every platform already knows how to render, and the reason
24
26
  a JS-less page can still set a birthday at all.
25
- JS — the calendar below. `alpine` comes from the enclosing
27
+ JS — the three selects. `alpine` comes from the enclosing
26
28
  studioProfileForm and is true only once that component has booted.
27
-
28
- The operator asked for a calendar rather than an open field (2026-08-15).
29
29
  %>
30
- <label class="block text-sm text-secondary mb-2 font-medium" for="profile_birthday">Date of birth</label>
31
-
30
+ <%# NO FIELD LABEL HERE, deliberately (operator's call, 2026-08-15). The row is
31
+ already titled "Birthday" by the section wrapper, and the three selects carry
32
+ their own MONTH / DAY / YEAR labels — a "Date of birth" line between the two
33
+ said the same thing a third time. The no-JS <input type="date"> keeps its id
34
+ so a label elsewhere could still point at it. %>
32
35
  <%
33
36
  y = user.birth_year
34
37
  m = user.birth_month
@@ -36,7 +39,7 @@
36
39
  value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
37
40
  %>
38
41
 
39
- <div x-data="studioBirthdayPicker('<%= value %>')">
42
+ <div x-data="studioBirthdayFields('<%= value %>')">
40
43
  <%# --- the JS-less path ------------------------------------------------- %>
41
44
  <template x-if="!alpine">
42
45
  <input type="date" name="profile[birthday]" id="profile_birthday"
@@ -44,187 +47,37 @@
44
47
  autocomplete="bday" max="<%= Date.current.iso8601 %>">
45
48
  </template>
46
49
 
47
- <%# --- the calendar ------------------------------------------------------ %>
50
+ <%# --- the three selects -------------------------------------------------- %>
48
51
  <template x-if="alpine">
49
52
  <div>
50
- <%# The value the form actually submits. The calendar writes here; nothing
51
- types into it. %>
52
- <input type="hidden" name="profile[birthday]" :value="value">
53
-
54
- <button type="button" x-ref="trigger" @click="toggle()"
55
- @keydown.escape="open = false"
56
- :aria-expanded="open ? 'true' : 'false'"
57
- aria-haspopup="dialog"
58
- class="input-field w-full text-left flex items-center justify-between gap-2">
59
- <span :class="displayValue ? 'text-heading' : 'text-muted'"
60
- x-text="displayValue || 'Select your date of birth'"></span>
61
- <svg class="w-4 h-4 text-muted flex-shrink-0" fill="none" stroke="currentColor"
62
- stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
63
- <path stroke-linecap="round" stroke-linejoin="round"
64
- d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/>
65
- </svg>
66
- </button>
67
-
68
- <div x-show="open" x-cloak
69
- x-ref="popover"
70
- @click.outside="open = false"
71
- @keydown.escape.window="open = false"
72
- @scroll.window="place()" @resize.window="place()"
73
- role="dialog" aria-label="Choose your date of birth"
74
- class="studio-birthday-popover"
75
- :style="'top:' + top + 'px; left:' + left + 'px; min-width:' + width + 'px;'">
76
-
77
- <%# Month and year JUMP rather than step. Stepping from today to 1985 is
78
- about four hundred clicks, which is the whole reason this is not the
79
- contest picker. %>
80
- <div class="studio-birthday-nav">
81
- <select x-model.number="viewMonth" class="input-field studio-birthday-month"
82
- aria-label="Month">
83
- <template x-for="(name, i) in months" :key="name">
84
- <option :value="i" x-text="name"></option>
85
- </template>
86
- </select>
87
- <select x-model.number="viewYear" class="input-field studio-birthday-year"
88
- aria-label="Year">
89
- <template x-for="y in years" :key="y">
90
- <option :value="y" x-text="y"></option>
91
- </template>
92
- </select>
93
- </div>
94
-
95
- <div class="studio-birthday-grid studio-birthday-weekdays">
96
- <template x-for="(wd, i) in weekdays" :key="i">
97
- <div x-text="wd"></div>
98
- </template>
99
- </div>
100
-
101
- <div class="studio-birthday-grid">
102
- <template x-for="(cell, i) in calDays()" :key="i">
103
- <div>
104
- <template x-if="cell">
105
- <button type="button" @click="pick(cell)" :disabled="isFuture(cell)"
106
- :class="isSelected(cell) && 'is-selected'"
107
- class="studio-birthday-day"
108
- x-text="cell"></button>
109
- </template>
110
- </div>
111
- </template>
112
- </div>
113
-
114
- <%# A birthday already set is the only case where clearing is meaningful,
115
- and without this the calendar is a one-way door — every day cell sets
116
- a value and none of them unset one. %>
117
- <div class="mt-3 flex justify-end" x-show="value">
118
- <button type="button" @click="clear()"
119
- class="text-xs text-secondary hover:text-heading transition">
120
- Clear
121
- </button>
122
- </div>
123
- </div>
53
+ <%# The value the form actually submits. The selects write here; nothing
54
+ types into it.
55
+
56
+ THE NAME IS BOUND, AND THAT IS THE WHOLE POINT. A partly-filled date
57
+ has no ISO value, and submitting a BLANK birthday is not "no change" to
58
+ this controller — it is DELETE, and it clears all three columns on
59
+ purpose (see #birthday_attributes: someone deleting their birthday
60
+ means it). Those two facts met in a data-loss bug: a stored Jan 31,
61
+ switch the month to February, onMonthChange blanks the 31st because
62
+ February has no 31st, and the form is now one Save away from wiping a
63
+ birthday the person never asked to delete.
64
+
65
+ On THIS path a blank is always an edit in progress and never an
66
+ intentional clear, because the selects offer no empty option — once a
67
+ part is set the user cannot unset it. The intentional clear lives on
68
+ the no-JS <input type="date"> above, where it is a real gesture.
69
+
70
+ So while the date is incomplete the field submits NOTHING: Alpine
71
+ removes an attribute bound to false, an input with no name is not
72
+ submitted, and the controller's `return {} if raw.nil?` makes an absent
73
+ param a no-op. The birthday is left exactly as it was. %>
74
+ <input type="hidden" :name="complete ? 'profile[birthday]' : false" :value="isoValue">
75
+
76
+ <%= render "studio/fields/date_of_birth" %>
124
77
  </div>
125
78
  </template>
126
79
  </div>
127
80
 
128
81
  <p class="text-muted text-xs mt-2">Stored as day, month and year — we use it for birthdays and age checks.</p>
129
82
 
130
- <style>
131
- /* EVERY RULE BELOW IS OWNED CSS, NOT TAILWIND UTILITIES, and that is not a
132
- style preference — it is the bug the operator reported.
133
-
134
- The engine ships a PREBUILT bundle to consumers. A Tailwind utility exists in
135
- a consuming app only if that app's OWN views already emitted it, because its
136
- build scans its own source, not the gem's. This calendar first shipped using
137
- `grid grid-cols-7`, and `grid-cols-7` is rare enough that NO consumer had
138
- ever emitted it: measured in mcritchie-studio's compiled bundle, zero
139
- occurrences. With no grid, the seven weekday letters stacked into a single
140
- vertical column and the popover grew to the height of the page.
141
- `hover:bg-surface-alt` was missing for the same reason.
142
-
143
- The same note is on studio/profiles/_identity_styles for the same reason.
144
-
145
- AND THE BROWSER LANE COULD NOT SEE IT. e2e/tailwind_input.css carries
146
- `@source "../app/views"`, so the lane compiles the engine's OWN views and
147
- emits grid-cols-7 — every spec passed against a page no consumer renders.
148
- That blindness is recorded in docs/E2E_LANE.md; the fix here is to stop
149
- depending on the utility at all. */
150
- .studio-birthday-nav {
151
- display: flex;
152
- align-items: center;
153
- gap: 0.5rem;
154
- margin-bottom: 0.75rem;
155
- }
156
-
157
- .studio-birthday-month { flex: 1 1 auto; }
158
-
159
- .studio-birthday-month,
160
- .studio-birthday-year {
161
- font-size: 0.875rem;
162
- padding-top: 0.25rem;
163
- padding-bottom: 0.25rem;
164
- }
165
-
166
- /* THE ONE THAT BROKE. Seven columns, owned outright. */
167
- .studio-birthday-grid {
168
- display: grid;
169
- grid-template-columns: repeat(7, minmax(0, 1fr));
170
- text-align: center;
171
- }
172
-
173
- .studio-birthday-weekdays {
174
- margin-bottom: 0.25rem;
175
- font-size: 0.75rem;
176
- font-weight: 500;
177
- color: var(--color-text-muted, #9ca3af);
178
- }
179
-
180
- .studio-birthday-weekdays > div { padding: 0.25rem 0; }
181
-
182
- .studio-birthday-day {
183
- width: 2rem;
184
- height: 2rem;
185
- border-radius: 9999px;
186
- font-size: 0.875rem;
187
- color: var(--color-text-body, inherit);
188
- transition: background-color 150ms ease, color 150ms ease;
189
- }
190
-
191
- .studio-birthday-day:hover:not(:disabled) {
192
- background: var(--color-surface-alt, rgba(127, 127, 127, 0.15));
193
- }
194
-
195
- .studio-birthday-day:disabled {
196
- opacity: 0.4;
197
- cursor: not-allowed;
198
- }
199
-
200
- .studio-birthday-day.is-selected {
201
- background: var(--color-primary);
202
- color: #fff;
203
- font-weight: 700;
204
- }
205
-
206
- /* FIXED, not absolute, for TWO reasons — and the second is the one a spec can
207
- actually catch.
208
-
209
- 1. Clipping. The edit page's rows sit inside `card p-0 overflow-hidden`. An
210
- absolutely-positioned popover only escapes that clip while NO ancestor is
211
- positioned; the day a consumer's card grows a `position: relative` it is
212
- clipped out of sight, with perfectly correct markup.
213
- 2. Anchoring. place() writes VIEWPORT coordinates from the trigger's rect.
214
- Fixed keeps them meaningful as the page scrolls; absolute reinterprets
215
- them as document coordinates and the popover drifts by the scroll offset.
216
-
217
- At scroll 0 the two are indistinguishable, which is why the spec that proves
218
- this scrolls first. */
219
- .studio-birthday-popover {
220
- position: fixed;
221
- z-index: 40;
222
- background: var(--color-surface);
223
- border: 1px solid var(--color-border-subtle);
224
- border-radius: 0.75rem;
225
- box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.25);
226
- padding: 0.75rem;
227
- }
228
- </style>
229
-
230
83
  <%= render "studio/profiles/birthday_picker_script" %>
@@ -1,40 +1,48 @@
1
- <%# The birthday calendar's Alpine component.
2
-
3
- Its own partial so the field markup stays readable and this can be reasoned
4
- about as behaviour.
5
-
6
- WHY NOT REUSE turf-monster's contest_lock_picker. That component is the
7
- nearest thing in the ecosystem and its GRID is where this one's shape came
8
- from, but its navigation is built for the opposite problem: picking a lock
9
- time a few weeks OUT, by stepping months from today. Stepping to 1985 that
10
- way is roughly four hundred clicks. A birthday needs to JUMP — hence the year
11
- and month selects in the header — and it needs the future closed off, which a
12
- contest picker specifically must not do.
13
-
14
- WHY `position: fixed` AND NOT `absolute`. The edit page renders its rows
15
- inside `card p-0 overflow-hidden`, which CLIPS an absolutely-positioned child
16
- the popover would open and be invisible below the row. Fixed takes it out
17
- of the clipping context entirely, at the cost of having to place it by hand
18
- from the trigger's rect and re-place it while it is open. The compact identity
19
- header is fixed for a related reason, so this is the file's second instance of
20
- the same trade rather than a one-off.
1
+ <%# The birthday field's Alpine component.
2
+
3
+ Supplies exactly what studio/fields/_date_of_birth binds to month, day,
4
+ year, months, dayOptions, years, onMonthChange() — plus the one thing the
5
+ profile page needs on top: it writes the assembled ISO date into the
6
+ enclosing studioProfileForm's `fields.birthday`, which is what raises the
7
+ save bar.
8
+
9
+ THIS REPLACED A CALENDAR POPOVER, and the replacement is the point. That
10
+ component shipped four defects in three days:
11
+
12
+ · its day grid used `grid-cols-7`, which exists in NO consumer bundle, so
13
+ the seven weekday letters stacked into one column and the popover grew to
14
+ the height of the page;
15
+ · a flipped popover positioned itself from a hardcoded 340px height when
16
+ the real popover is ~245px, landing ~95px too high;
17
+ · place() ran on every scroll and RE-DECIDED the side, so it snapped
18
+ between above and below mid-scroll a 254px jump between two 60px steps;
19
+ · and it came back OPEN after a browser Back.
20
+
21
+ Three selects have none of those failure modes. There is no position, no
22
+ flip, no popover, and no scroll listener — so there is nothing to place
23
+ wrongly, nothing to re-decide, and nothing to restore open.
21
24
  %>
22
25
  <script>
23
- window.studioBirthdayPicker = function (initial) {
26
+ window.studioBirthdayFields = function (initial) {
27
+ var parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(initial || ""));
28
+
24
29
  return {
25
- open: false,
26
- // Which side this open chose. Set by chooseSide() when the popover opens and
27
- // then left alone — see place() for why re-deciding it is the bug.
28
- flipped: false,
29
- value: initial || "",
30
- viewYear: 0,
31
- viewMonth: 0,
32
- top: 0,
33
- left: 0,
34
- width: 0,
30
+ year: parts ? String(+parts[1]) : "",
31
+ month: parts ? String(+parts[2]) : "",
32
+ day: parts ? String(+parts[3]) : "",
33
+
34
+ months: [
35
+ { n: 1, label: "January" }, { n: 2, label: "February" },
36
+ { n: 3, label: "March" }, { n: 4, label: "April" },
37
+ { n: 5, label: "May" }, { n: 6, label: "June" },
38
+ { n: 7, label: "July" }, { n: 8, label: "August" },
39
+ { n: 9, label: "September" }, { n: 10, label: "October" },
40
+ { n: 11, label: "November" }, { n: 12, label: "December" }
41
+ ],
35
42
 
36
43
  // Oldest year offered. 120 covers every living person with room to spare;
37
- // the point is a bounded list, because a select has to end somewhere.
44
+ // the newest is THIS year, because a birthday cannot be in the future and
45
+ // the cheapest way to say so is not to offer one.
38
46
  get years() {
39
47
  var now = new Date().getFullYear();
40
48
  var out = [];
@@ -42,153 +50,96 @@
42
50
  return out;
43
51
  },
44
52
 
45
- months: ["January", "February", "March", "April", "May", "June",
46
- "July", "August", "September", "October", "November", "December"],
47
-
48
- weekdays: ["S", "M", "T", "W", "T", "F", "S"],
49
-
50
- init() {
51
- this.syncViewFromValue();
52
- },
53
-
54
- // The month the grid opens on: the chosen birthday when there is one, else
55
- // a sensible landing spot. NOT today opening a birthday picker on the
56
- // current year means everyone starts 30+ years from where they are going.
57
- syncViewFromValue() {
58
- var parts = this.parse(this.value);
59
- var now = new Date();
60
- this.viewYear = parts ? parts.y : now.getFullYear() - 30;
61
- this.viewMonth = parts ? parts.m - 1 : 0;
62
- },
63
-
64
- parse(iso) {
65
- var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ""));
66
- if (!m) return null;
67
- return { y: +m[1], m: +m[2], d: +m[3] };
68
- },
69
-
70
- get displayValue() {
71
- var p = this.parse(this.value);
72
- if (!p) return "";
73
- return this.months[p.m - 1] + " " + p.d + ", " + p.y;
53
+ // The days that exist in the chosen month — 30 in April, and 29 in a
54
+ // February only when the chosen year is a leap year. Day 0 of the NEXT
55
+ // month is the last day of this one, which is the standard trick and
56
+ // handles the leap rule without one being written down.
57
+ get dayOptions() {
58
+ var m = parseInt(this.month, 10);
59
+ var y = parseInt(this.year, 10) || 2000; // a leap year, so Feb offers 29 before a year is picked
60
+ if (!m) return Array.from({ length: 31 }, function (_, i) { return i + 1; });
61
+
62
+ var count = new Date(y, m, 0).getDate();
63
+ return Array.from({ length: count }, function (_, i) { return i + 1; });
74
64
  },
75
65
 
76
- // Leading blanks so the 1st lands under its real weekday, then the days.
77
- calDays() {
78
- var first = new Date(this.viewYear, this.viewMonth, 1).getDay();
79
- var count = new Date(this.viewYear, this.viewMonth + 1, 0).getDate();
80
- var cells = [];
81
- for (var i = 0; i < first; i++) cells.push(null);
82
- for (var d = 1; d <= count; d++) cells.push(d);
83
- return cells;
66
+ get complete() {
67
+ return !!(this.year && this.month && this.day);
84
68
  },
85
69
 
86
- // A birthday cannot be in the future. Checked per-DAY rather than by
87
- // clamping the year, because the boundary month is half valid.
88
- isFuture(day) {
89
- var candidate = new Date(this.viewYear, this.viewMonth, day);
90
- var today = new Date();
91
- today.setHours(0, 0, 0, 0);
92
- return candidate > today;
70
+ get isoValue() {
71
+ if (!this.complete) return "";
72
+ return this.year + "-" +
73
+ String(this.month).padStart(2, "0") + "-" +
74
+ String(this.day).padStart(2, "0");
93
75
  },
94
76
 
95
- isSelected(day) {
96
- var p = this.parse(this.value);
97
- return !!p && p.y === this.viewYear && p.m === this.viewMonth + 1 && p.d === day;
77
+ // Named to match what the shared field binds to. Clears a day that the new
78
+ // month or year does not have — pick 31 January then switch to February and
79
+ // the 31st stops existing then publishes.
80
+ onMonthChange() {
81
+ if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
82
+ this.publish();
98
83
  },
99
84
 
100
- pick(day) {
101
- if (this.isFuture(day)) return;
102
- var mm = String(this.viewMonth + 1).padStart(2, "0");
103
- var dd = String(day).padStart(2, "0");
104
- this.value = this.viewYear + "-" + mm + "-" + dd;
105
-
106
- // `fields` belongs to the enclosing studioProfileForm component. Alpine's
107
- // scope chain makes it reachable from here, and it is the SAME object, so
108
- // assigning through it is what raises the save bar. Without this line the
109
- // picker would set a value the dirty check never notices.
110
- if (this.fields) this.fields.birthday = this.value;
111
-
112
- this.open = false;
113
- },
114
-
115
- clear() {
116
- this.value = "";
117
- if (this.fields) this.fields.birthday = "";
118
- this.syncViewFromValue();
85
+ // WHAT THE DIRTY CHECK IS SHOWN, and why it is not isoValue.
86
+ //
87
+ // isoValue is "" for EVERY incomplete date, so publishing it made a whole
88
+ // class of edit invisible: on an empty birthday, choosing a month wrote ""
89
+ // over "" and the save bar never appeared. The operator reported exactly
90
+ // that. Choosing a month IS a change — the form just had no way to see it.
91
+ //
92
+ // So the signal is the three parts, which differ whenever any one of them
93
+ // does. It stays equal to isoValue for a COMPLETE date (same string, same
94
+ // shape) so a finished edit compares correctly against the value the page
95
+ // loaded with, and Discard restores it to that same string.
96
+ //
97
+ // `fields` belongs to the enclosing studioProfileForm — Alpine's scope
98
+ // chain makes it reachable and it is the SAME object, so assigning through
99
+ // it is what raises the save bar.
100
+ // THE EMPTY CASE IS NOT A PARTIAL ONE, and conflating them makes the page
101
+ // arrive already dirty. studioProfileForm's initial.birthday is "" for a
102
+ // person with no birthday on file, so an untouched empty field must also
103
+ // read "" — joining three blanks gives "--", which compares as a change and
104
+ // would raise the save bar on load, before anyone touched anything.
105
+ get signature() {
106
+ if (this.complete) return this.isoValue;
107
+ if (!this.year && !this.month && !this.day) return "";
108
+ return [this.year, this.month, this.day].join("-");
119
109
  },
120
110
 
121
- toggle() {
122
- this.open = !this.open;
123
- if (!this.open) return;
124
-
125
- this.syncViewFromValue();
126
-
127
- // Placed twice, deliberately. The first call stops the popover appearing
128
- // at 0,0 for a frame; the second is the one that can MEASURE, because
129
- // offsetHeight is 0 while x-show still has the element hidden — and it is
130
- // also the only place the SIDE is chosen, for the life of this open.
131
- this.place();
132
- this.$nextTick(function () {
133
- this.chooseSide();
134
- this.place();
135
- }.bind(this));
111
+ publish() {
112
+ if (this.fields) this.fields.birthday = this.signature;
136
113
  },
137
114
 
138
- // Placed from the trigger's rect because the popover is fixed. Flips above
139
- // the trigger when there is not room below, so it never opens off-screen on
140
- // a short viewport.
115
+ // DISCARD MUST REACH THE SELECTS. studioProfileForm#discard reassigns its
116
+ // `fields` object, which restores every input bound with x-model and the
117
+ // three selects are not, because they are bound to THIS component's own
118
+ // month/day/year. So Discard used to leave them showing the abandoned edit
119
+ // while the bar disappeared, and the hidden input went on carrying it: the
120
+ // next save would write the change the user had just discarded.
141
121
  //
142
- // THE HEIGHT IS MEASURED, NOT GUESSED, and that distinction is the bug the
143
- // operator reported. This used a hardcoded `estimatedHeight = 340` for both
144
- // the decision AND the flipped coordinate. The real popover is about 245px,
145
- // so a flip landed ~95px too high and the calendar floated visibly detached
146
- // from its field — and since scroll re-runs this, it kept jumping. The
147
- // arithmetic was legible in the screenshot: trigger at 640, popover at 300,
148
- // and 640 - 340 - 4 = 316.
149
- //
150
- // The element is rendered under x-show (display is toggled, it stays in the
151
- // DOM), so offsetHeight is readable as soon as it is shown. The constant
152
- // survives only as the fallback for the first call, before the ref exists.
153
- place() {
154
- var el = this.$refs.trigger;
155
- if (!el) return;
156
-
157
- var r = el.getBoundingClientRect();
158
- var popover = this.$refs.popover;
159
- var height = (popover && popover.offsetHeight) || 340;
160
- var GAP = 4;
161
-
162
- this.left = r.left;
163
- this.width = r.width;
164
-
165
- // THE SIDE IS NOT RE-DECIDED HERE. place() runs on every scroll and
166
- // resize event, and choosing the side each time makes the popover SNAP
167
- // between above and below the moment the available space crosses the
168
- // threshold. Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE,
169
- // ABOVE, ABOVE, then below — a 254px jump between two steps, mid-scroll,
170
- // while the reader was looking at it. That is what "the date picker
171
- // freaks out on scroll" was.
172
- //
173
- // chooseSide() runs ONCE per open; this only moves the popover.
174
- this.top = this.flipped ? r.top - height - GAP : r.bottom + GAP;
122
+ // Watching `fields.birthday` is what joins them. Discard sets it back to
123
+ // the initial string, this fires, and the selects follow and because the
124
+ // guard below returns early when the value already matches, the publish
125
+ // watch publish path cannot loop.
126
+ init() {
127
+ var self = this;
128
+ this.publish();
129
+ this.$watch("fields.birthday", function (value) {
130
+ if (value === self.signature) return;
131
+ self.adopt(value);
132
+ });
175
133
  },
176
134
 
177
- // Which side to open on, decided once and then left alone. Flips above only
178
- // when there is not room below AND there is room above with neither, it
179
- // stays below, attached to its field rather than pinned to the top of the
180
- // viewport.
181
- chooseSide() {
182
- var el = this.$refs.trigger;
183
- if (!el) return;
184
-
185
- var r = el.getBoundingClientRect();
186
- var popover = this.$refs.popover;
187
- var height = (popover && popover.offsetHeight) || 340;
188
- var GAP = 4;
189
- var below = window.innerHeight - r.bottom;
190
-
191
- this.flipped = below < height + GAP && r.top > height + GAP;
135
+ // Takes an ISO date (or a blank) and puts the selects on it. Anything that
136
+ // is not a full date empties all three, which is what an initial blank and
137
+ // a cleared birthday both mean.
138
+ adopt(value) {
139
+ var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ""));
140
+ this.year = m ? String(+m[1]) : "";
141
+ this.month = m ? String(+m[2]) : "";
142
+ this.day = m ? String(+m[3]) : "";
192
143
  }
193
144
  };
194
145
  };
@@ -297,12 +297,22 @@
297
297
  </div>
298
298
  <% end %>
299
299
 
300
+ <%# The glow host is a BARE wrapper and the opaque face is a CHILD. The ring
301
+ pseudos sit at z-index -1/-2, and a negative-z descendant paints ABOVE its
302
+ host's own background — so a background on the HOST washes the whole card
303
+ face and there is no ring left. The child in front is what covers the
304
+ middle, exactly as TM's holo-wrap / holo-card split does. See
305
+ engine-motion.css §9b. %>
300
306
  <%= render layout: "style/specimen",
301
307
  locals: { klass: ".studio-team-glow (knob: --studio-team-glow-color)",
302
- usage: %(<div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>\n<div class="studio-team-glow rounded-xl bg-surface p-5" style="--studio-team-glow-color: #E11D48">Ravens</div>) } do %>
308
+ usage: %(<div class="studio-team-glow rounded-xl">\n <div class="rounded-xl bg-surface p-5">Selected</div>\n</div>) } do %>
303
309
  <div class="flex flex-wrap items-center justify-center gap-6">
304
- <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
305
- <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center" style="--studio-team-glow-color: #E11D48">Ravens</div>
310
+ <div class="studio-team-glow rounded-xl">
311
+ <div class="rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
312
+ </div>
313
+ <div class="studio-team-glow rounded-xl" style="--studio-team-glow-color: #E11D48">
314
+ <div class="rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Ravens</div>
315
+ </div>
306
316
  </div>
307
317
  <% end %>
308
318
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.53.1"
2
+ VERSION = "0.54.1"
3
3
  end
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.53.1
4
+ version: 0.54.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -298,6 +298,7 @@ files:
298
298
  - app/views/studio/emails/index.html.erb
299
299
  - app/views/studio/emails/orphan.html.erb
300
300
  - app/views/studio/emails/show.html.erb
301
+ - app/views/studio/fields/_date_of_birth.html.erb
301
302
  - app/views/studio/links/confirm.html.erb
302
303
  - app/views/studio/local_emails/index.html.erb
303
304
  - app/views/studio/mailers/_layered_banner.html.erb