studio-engine 0.129.0 → 0.130.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.
@@ -73,6 +73,11 @@ module Studio
73
73
  "--fd:#{bit[:delay]}s;--ft:#{bit[:duration]}s;--fc:#{fizz_color(bit)}"
74
74
  end
75
75
 
76
+ # A fixed palette as the inline style a stack carries: `--fizz-c-1:red;...`.
77
+ def fizz_stack_style(colors)
78
+ Array(colors).first(SLOTS).each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";")
79
+ end
80
+
76
81
  private
77
82
 
78
83
  # Where in its own zone a bubble sits. The outer columns each throw one
@@ -11,10 +11,10 @@ import { activeIndex } from "studio/style_section_nav"
11
11
 
12
12
  const ACTIVE = "btn-primary"
13
13
  const REST = "btn-neutral"
14
- // A jump lands a section by its scroll-margin (--nav-h + 5rem), read while the
15
- // header may still be tall; the header then collapses, so the row rises and the
16
- // section does not. The full margin keeps the clicked section past the line,
17
- // with the collapse's height to spare, for any later scroll or resize.
14
+ // A jump lands a section by its scroll margin, the stack's pinned heights, read while the
15
+ // header may still be tall; the header then collapses, so the row rises and the section
16
+ // does not (76px on the hub at phone width). The slack keeps the clicked section past the
17
+ // line, for any later scroll or resize.
18
18
  const LINE_SLACK = 80
19
19
 
20
20
  export default class extends Controller {
@@ -37,6 +37,11 @@
37
37
  // SIZE something as tall as the header; use --nav-bottom to START something
38
38
  // underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
39
39
  //
40
+ // THE SECTION LAYER (studio/_section_header, docs/PAGE_HEADER.md) IS THE ONE LAYER
41
+ // THAT IS NOT A data-pin. A page has many section headers and only one is stuck, so
42
+ // each carries `data-pin-section`, and together they publish as one layer, `section`.
43
+ // See stuckSection below.
44
+ //
40
45
  // Started once per document by studio/application. The listeners it adds live
41
46
  // for the document, and a Turbo visit republishes through them rather than
42
47
  // starting it again.
@@ -152,6 +157,62 @@ export function documentIndex(list, el) {
152
157
  return list.length;
153
158
  }
154
159
 
160
+ // THE SECTION LAYER: MANY HEADERS, ONE LAYER, ONLY THE STUCK ONE COUNTS.
161
+ //
162
+ // A section header is sticky inside its own <section>, so the browser does the
163
+ // swap: the next section's header arrives as the last one is pushed out. It joins
164
+ // the stack so that a table's sticky header and any other reader of
165
+ // --pin-stack-bottom sit under it. It cannot be a plain [data-pin]: composed() takes
166
+ // the lowest edge of every layer, so a header waiting below the fold would drag
167
+ // --pin-stack-bottom down the page, and headers sharing a name would overwrite each
168
+ // other's values.
169
+ //
170
+ // So all of them are one layer named `section`, always in the registry while any is
171
+ // on the page. The roster never changes per swap, so a swap is not a structural write,
172
+ // and --pin-section-top, which every header positions off, stays published when none
173
+ // is stuck. That layer's height is 0 until one is stuck, and then it is the part of the
174
+ // bar still below the layers above, so a header being pushed out gives its room back
175
+ // frame by frame.
176
+ //
177
+ // rects are { top, bottom, parentTop, parentBottom }, in page order; line is
178
+ // --pin-section-top. A header is stuck once it has left its place in the flow and part
179
+ // of it still shows below the line. Its section's top is that place, since it is the
180
+ // section's first child and the section has no top padding.
181
+ //
182
+ // NEITHER FACT READS THE HEADER'S OWN EDGE AGAINST THE LINE. While the navbar collapses
183
+ // the line moves first and the header's `top` follows a publish later, so a comparison
184
+ // would drop the bar for the whole collapse, and a bottom read off the rect would be
185
+ // published one step stale, with nothing left to correct it once the page is still. So
186
+ // the bar's bottom is what sticky will make it: one height under the line, or the end of
187
+ // its section while that pushes it out.
188
+ export function sectionBottom(r, line) {
189
+ return Math.min(line + (r.bottom - r.top), r.parentBottom);
190
+ }
191
+
192
+ export function stuckSection(rects, line) {
193
+ var found = -1;
194
+ for (var i = 0; i < rects.length; i++) {
195
+ var r = rects[i];
196
+ if (r.top - r.parentTop > 0.5 && sectionBottom(r, line) > line + 0.5) found = i;
197
+ }
198
+ return found;
199
+ }
200
+
201
+ function measureSections(nodes, line) {
202
+ // Below the short-screen floor, or before the publisher runs, the CSS leaves these in
203
+ // the flow (engine.css, @utility section-header). A header scrolling past the line
204
+ // there is not stuck, so it must not count.
205
+ if (window.getComputedStyle(nodes[0]).position !== 'sticky') return null;
206
+ var rects = [];
207
+ for (var i = 0; i < nodes.length; i++) {
208
+ var r = nodes[i].getBoundingClientRect();
209
+ var parent = nodes[i].parentElement.getBoundingClientRect();
210
+ rects.push({ top: r.top, bottom: r.bottom, parentTop: parent.top, parentBottom: parent.bottom });
211
+ }
212
+ var at = stuckSection(rects, line);
213
+ return at < 0 ? null : { el: nodes[at], bottom: sectionBottom(rects[at], line) };
214
+ }
215
+
155
216
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
156
217
  //
157
218
  // This used to build a `pins` array once and hold each element by reference
@@ -168,8 +229,9 @@ export function documentIndex(list, el) {
168
229
  // reference IMPOSSIBLE rather than merely unlikely: there is no window between
169
230
  // a DOM patch and a re-scan in which this can be pointed at the wrong node.
170
231
  //
171
- // Returns { pins, stack, timeline }: every layer measured and composed, the
172
- // stack's bottom, and whether the header is on the scroll timeline.
232
+ // Returns { pins, stack, timeline, travel, sections, stuck }: every layer measured and
233
+ // composed, the stack's bottom, whether the header is on the scroll timeline and how
234
+ // far it travels, the section headers, and which of them is stuck.
173
235
  export function readStack() {
174
236
  var header = document.querySelector('header');
175
237
  var timeline = onTimeline(header);
@@ -191,11 +253,28 @@ export function readStack() {
191
253
  // never publish at all. Both live consumers own their header, so it is
192
254
  // adopted as a pin even when it does not ask to be.
193
255
  if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
256
+ // In the registry at height 0 first, so the composition says where its line is.
257
+ var sections = document.querySelectorAll('[data-pin-section]');
258
+ var section = null;
259
+ if (sections.length) {
260
+ section = { el: sections[0], name: 'section', legacy: false, h: 0, bottom: 0 };
261
+ list.splice(documentIndex(list, sections[0]), 0, section);
262
+ }
263
+ var stack = timeline ? pinnedAt(list) : composed(list);
264
+ var stuck = section ? measureSections(sections, section.top) : null;
265
+ if (stuck) {
266
+ section.el = stuck.el;
267
+ section.h = stuck.bottom - section.top;
268
+ section.bottom = stuck.bottom;
269
+ stack = timeline ? pinnedAt(list) : composed(list);
270
+ }
194
271
  return {
195
272
  pins: list,
196
- stack: timeline ? pinnedAt(list) : composed(list),
273
+ stack: stack,
197
274
  timeline: timeline,
198
- travel: timeline ? timelineTravel(header) : 0
275
+ travel: timeline ? timelineTravel(header) : 0,
276
+ sections: sections,
277
+ stuck: stuck ? stuck.el : null
199
278
  };
200
279
  }
201
280
 
@@ -414,6 +493,9 @@ export function startPinnedStack() {
414
493
  settleTimer = null;
415
494
  publishAll();
416
495
  releaseFollowers();
496
+ // That publish measured the layers while they still held their last copies. Given back,
497
+ // they move to the root's values, so their edges are read once more.
498
+ schedule();
417
499
  }
418
500
 
419
501
  // The registry and the measurement are module-level (readStack, measurePin
@@ -505,12 +587,38 @@ export function startPinnedStack() {
505
587
  }
506
588
  }
507
589
 
590
+ // WHO WEARS THE BAR, written after every read of the publish. The box does not change
591
+ // with the attribute, so this resizes nothing and moves nothing. The root's attribute
592
+ // squares the page row's corners against the bar under it (engine.css, glass).
593
+ function syncSections(reading) {
594
+ for (var i = 0; i < reading.sections.length; i++) {
595
+ var node = reading.sections[i];
596
+ var on = node === reading.stuck;
597
+ if (node.hasAttribute('data-stuck') !== on) node.toggleAttribute('data-stuck', on);
598
+ }
599
+ var root = document.documentElement;
600
+ if (root.hasAttribute('data-pin-section-stuck') !== !!reading.stuck) root.toggleAttribute('data-pin-section-stuck', !!reading.stuck);
601
+ }
602
+
508
603
  var lastPublished = null;
604
+ var lastTops = null;
509
605
  function publishAll() {
606
+ // What lets a section header stick at all (engine.css): with no publisher its bar
607
+ // would never come, and it would lay a clear title over the page. Before the read,
608
+ // so the first publish on a restored scroll already sees it sticky.
609
+ var html = document.documentElement;
610
+ if (!html.hasAttribute('data-pin-live')) html.setAttribute('data-pin-live', '');
510
611
  var reading = readStack();
511
612
  var pins = reading.pins;
512
613
  write(pins, reading.stack, reading.timeline, reading.travel);
614
+ syncSections(reading);
513
615
  syncObserver(pins);
616
+ // A LAYER UNDER A LAYER SETTLES ONE PUBLISH LATER. A new --pin-page-top moves the page
617
+ // row without resizing it, so nothing wakes the publisher to read the row's new edge,
618
+ // and the layer under the row would keep a stale line once the page is still. So a
619
+ // publish that moved any layer's top asks for one more frame. It ends when none moved.
620
+ var tops = pins.map(function (p) { return p.top; }).join('|');
621
+ if (tops !== lastTops) { lastTops = tops; schedule(); }
514
622
  var key = publishedKey(reading);
515
623
  if (key !== lastPublished) {
516
624
  lastPublished = key;
@@ -0,0 +1,26 @@
1
+ <%#
2
+ Fizz around any button: bubbles that rise from behind it. Render it as a layout,
3
+ with the button as the block.
4
+
5
+ render layout: "studio/fizz", locals: { seed: "save" } do
6
+ <button class="btn btn-primary">Save</button>
7
+ end
8
+
9
+ Locals:
10
+ seed - the string the bubble table is seeded from, so the scatter is stable
11
+ for this button and differs from its neighbours
12
+ level - :lively (default), which boils at rest and adds a layer on hover, or
13
+ :calm, which simmers at rest and boils on hover
14
+ colors - optional palette, up to 18, three per zone (--fizz-c-1..18)
15
+
16
+ Decoration only. The hold button keeps its own stack (studio/_hold_button).
17
+ %>
18
+ <% lively = local_assigns.fetch(:level, :lively).to_s == "lively" %>
19
+ <% fizz_style = fizz_stack_style(local_assigns[:colors]) %>
20
+ <span class="fizz-stack<%= " fizz-lively" if lively %>"<% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
21
+ <%= render "studio/fizz_layer", seed: seed, neutral: true %>
22
+ <% if lively %>
23
+ <%= render "studio/fizz_layer", seed: "#{seed}~extra", extra: true, neutral: true %>
24
+ <% end %>
25
+ <%= yield %>
26
+ </span>
@@ -7,10 +7,13 @@
7
7
  in each other's gaps rather than on top of each other)
8
8
  extra - true for the hover-only layer, which rests invisible and fades in
9
9
  when the pointer arrives (default: false)
10
+ neutral - true inside a .fizz-stack (studio/_fizz), a button with no hold, so
11
+ the layer carries the neutral class names (default: false)
10
12
 
11
13
  The two layers also split each zone's three colour slots: the resting one
12
14
  takes the first, the hover one alternates the second and third.
13
15
  Decoration only: aria-hidden here, pointer-events off in CSS.
14
16
  %>
15
17
  <% extra ||= false %>
16
- <span class="hold-fizz<%= " hold-fizz-extra" if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
18
+ <% neutral ||= false %>
19
+ <span class="<%= neutral ? "fizz-layer" : "hold-fizz" %><%= (neutral ? " fizz-layer-extra" : " hold-fizz-extra") if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
@@ -74,8 +74,7 @@
74
74
  <% fizz_portal = fizz && local_assigns[:fizz_portal] %>
75
75
  <%# Static palette (gallery, or any call site with fixed colors). A runtime
76
76
  palette rides fizz_bind instead. %>
77
- <% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
78
- .each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";") %>
77
+ <% fizz_style = fizz_stack_style(fizz_colors) %>
79
78
 
80
79
  <%# The stack exists so the fizz can sit BEHIND the button. Inside the button
81
80
  a negative z-index still paints above its own background (the button's
@@ -1,6 +1,7 @@
1
1
  <%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
2
- <%# THE PAGE HEADER: the second layer of the triple stack (docs/PAGE_HEADER.md).
3
- navbar, then this row, then a table's sticky header, each below the last.
2
+ <%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
3
+ navbar, then this row, then a section's header (studio/_section_header), then a
4
+ table's sticky header, each below the last.
4
5
 
5
6
  AT REST it has no background and no border: a title on the left, tabs on the
6
7
  right. ONCE STUCK under the navbar it takes the bar. Its box never changes
@@ -0,0 +1,23 @@
1
+ <%# locals: (title:, level: :h2, row_class: "-mx-4 px-4") %>
2
+ <%# THE SECTION HEADER: a section's title that sticks under the rest of the pinned stack
3
+ until the next section's header takes its place (docs/PAGE_HEADER.md).
4
+
5
+ RENDER IT AS THE FIRST DIRECT CHILD OF ITS <section>. A sticky element travels only
6
+ inside its parent, and that is what gives the swap: inside a wrapper div, it would
7
+ leave with the wrapper. No backdrop-filter, transform or overflow on an ancestor.
8
+
9
+ title the heading's text. The heading is the sticky element, so an intro can
10
+ follow as its next sibling.
11
+ level the tag. A page should stick one level only: one bar at a time.
12
+ row_class the bleed. The default fits a container with px-4, as the page row's does.
13
+
14
+ AT REST a normal title. STUCK it takes the bar. Its box never changes between the two.
15
+
16
+ IT JOINS THE STACK WITH data-pin-section, NOT data-pin. Every header on the page shares
17
+ one layer, `section`, and only the stuck one counts (studio/pinned_stack, stuckSection).
18
+ So it positions off --pin-section-top, which also stands when none is stuck. Anything
19
+ under the stack, such as a table's sticky header, sits under it with no edit. %>
20
+ <%= content_tag(level, title,
21
+ class: "section-header #{row_class}".strip,
22
+ style: "top: var(--pin-section-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: calc(var(--z-sticky, 30) - 1)",
23
+ data: { pin_section: "" }) %>
@@ -5,11 +5,9 @@
5
5
  An app that does not draw the gallery here (lookbook not in its bundle, or
6
6
  production without Studio.lookbook_in_production) says so instead of linking
7
7
  to a 404. -%>
8
- <section id="components" class="space-y-6" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
9
- <div class="space-y-1">
10
- <h2 class="type-h3 text-heading">Components</h2>
11
- <p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
12
- </div>
8
+ <section id="components" class="section-target space-y-6">
9
+ <%= render "studio/section_header", title: "Components" %>
10
+ <p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
13
11
 
14
12
  <% if Studio.lookbook_mounted? %>
15
13
  <ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
@@ -90,12 +90,9 @@
90
90
  A reader who assumes dsModals will build a mirror instead: a second copy of
91
91
  every card, free to drift from the one the app actually ships, which is the
92
92
  opposite of what a living style guide is for. %>
93
- <section id="host-modals" class="space-y-8" x-data="{}"
94
- style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
95
- <div class="space-y-1">
96
- <h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
97
- <p class="text-muted text-sm max-w-2xl">This app&rsquo;s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
98
- </div>
93
+ <section id="host-modals" class="section-target space-y-8" x-data="{}">
94
+ <%= render "studio/section_header", title: Studio.app_name %>
95
+ <p class="text-muted text-sm max-w-2xl">This app&rsquo;s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
99
96
 
100
97
  <%# The app's content. Reached only when it resolves - style/index.html.erb
101
98
  owns that guard, because the section nav needs the same answer. %>
@@ -9,11 +9,11 @@
9
9
  page when chatting (item 1).
10
10
  - an optional row of option toggles lets the user configure the modal
11
11
  before opening; their clicks/keys do NOT bubble to the card open (item 2).
12
- - an optional active-card GLOW (.studio-team-glow) that reactively lights
12
+ - an optional active-card GLOW (.edge-rotate-glow) that reactively lights
13
13
  when this card represents the currently-open modal + step, and slides to
14
14
  the next card as the step machine advances (items 5/6). The glow rides an
15
15
  un-clipped wrapper (the card itself is overflow-hidden), mirroring TM's
16
- holo-wrap / holo-card split, and fades via --studio-team-glow-opacity so
16
+ holo-wrap / holo-card split, and fades via --edge-rotate-glow-opacity so
17
17
  it transitions smoothly between cards.
18
18
 
19
19
  Locals:
@@ -53,10 +53,10 @@
53
53
  <%# Wrapper — carries the active-card glow (un-clipped) + the shared x-data. %>
54
54
  <%# h-full so the card stretches to its grid row's tallest (per-row equal
55
55
  height via the grid's default align-items: stretch). %>
56
- <div class="relative h-full<%= " studio-team-glow rounded-xl" if glow_when %>"
56
+ <div class="relative h-full<%= " edge-rotate-glow rounded-xl" if glow_when %>"
57
57
  x-data="<%= card_x_data %>"
58
58
  <%# Fail-closed default: a STATIC inline 0 out-specifies the primitive's CSS
59
- default (--studio-team-glow-opacity of 1) so the ring is OFF at first
59
+ default (--edge-rotate-glow-opacity of 1) so the ring is OFF at first
60
60
  paint, BEFORE Alpine hydrates the reactive :style below. Without it every
61
61
  glow card shows its ring until Alpine boots (the fail-open flash). The
62
62
  :style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH.
@@ -67,7 +67,7 @@
67
67
  the Turbo-nav store gap fixed in 0.26.1 (style/_modals dual-guards the
68
68
  dsModals registration so it survives Turbo visits, not just first load).
69
69
  So the guarantee is: static inline 0 PLUS a registered dsModals. %>
70
- <% if glow_when %>style="--studio-team-glow-opacity: 0" :style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
70
+ <% if glow_when %>style="--edge-rotate-glow-opacity: 0" :style="{ '--edge-rotate-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
71
71
  <article
72
72
  class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
73
73
  <%= 'aria-disabled="true"'.html_safe if disabled %>
@@ -150,14 +150,12 @@
150
150
  target moved. The engine primitive is untouched; its real submit still posts
151
151
  to the app-supplied submit_url. Kept on the section (always mounted) so it
152
152
  fires even as the age-gate card closes. %>
153
- <section id="modals" class="space-y-10" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
153
+ <section id="modals" class="section-target space-y-10"
154
154
  data-studio-controller="style-modals"
155
155
  x-data="{}" @ds-modal-close.window="$store.dsModals.close()"
156
156
  @age-verified.window="$store.dsModals.open('entry-confirmed', { txSignature: null, lobbyUrl: null, seedsEarned: 0 })">
157
- <div class="space-y-1">
158
- <h2 class="type-h3 text-heading">Modals</h2>
159
- <p class="text-muted text-sm">Click a card to open the real modal.</p>
160
- </div>
157
+ <%= render "studio/section_header", title: "Modals" %>
158
+ <p class="text-muted text-sm">Click a card to open the real modal.</p>
161
159
 
162
160
  <%# ---- The section's own modal host (dsModals) --------------------------
163
161
  $store.dsModals is the engine's modal stack under the guide's own name:
@@ -33,7 +33,7 @@
33
33
  sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
34
34
  [ "Google's credit lines", 600, 720, false ] ]
35
35
  %>
36
- <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
36
+ <section id="site-footer" class="section-subtarget space-y-5">
37
37
  <div class="space-y-1">
38
38
  <h3 class="type-h4 text-heading">Site footer and booking</h3>
39
39
  <p class="text-muted text-sm max-w-2xl">One layout line renders the footer, and <code class="font-mono text-2xs">config.booking_url</code> turns on booking.</p>
@@ -6,6 +6,7 @@
6
6
  klass (String, required) - the class label, shown prominently with a
7
7
  copy button so an agent can grab the exact class list.
8
8
  usage (String, optional) - a copyable usage snippet.
9
+ note (String, optional) - one short sentence on what it does.
9
10
  disabled (Boolean, optional) - render "disabled but present": the
10
11
  primitive stays visible + previewable but is muted/greyed
11
12
  and flagged, for a capability the host app has turned off
@@ -38,6 +39,9 @@
38
39
  <% end %>
39
40
  </div>
40
41
  <div class="border-t border-subtle p-3 space-y-2">
42
+ <% if local_assigns[:note].present? %>
43
+ <p class="text-sm text-body"><%= local_assigns[:note] %></p>
44
+ <% end %>
41
45
  <%# Class name — prominent, with a one-click copy so an agent can grab it. %>
42
46
  <div class="flex items-start justify-between gap-2" x-data="{ copied: false }">
43
47
  <code x-ref="klass" class="font-mono text-xs font-bold text-heading break-all leading-relaxed flex-1 min-w-0"><%= local_assigns[:klass] %></code>
@@ -63,11 +63,9 @@
63
63
  "stage-shipped" => [{ title: "SES email backend", repo: "mcritchie", who: "ST" }]
64
64
  }
65
65
  %>
66
- <section id="tasks" class="space-y-8" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
67
- <div class="space-y-1">
68
- <h2 class="type-h3 text-heading">Tasks</h2>
69
- <p class="text-muted text-sm max-w-2xl">The real board primitive in demo mode, so a drag saves nothing.</p>
70
- </div>
66
+ <section id="tasks" class="section-target space-y-8">
67
+ <%= render "studio/section_header", title: "Tasks" %>
68
+ <p class="text-muted text-sm max-w-2xl">The real board primitive in demo mode, so a drag saves nothing.</p>
71
69
 
72
70
  <%# ---- The LIVE primitive (demo mode) --------------------------------- %>
73
71
  <%# demo: true → drag animates + toasts locally, nothing POSTs. %>
@@ -53,11 +53,11 @@
53
53
 
54
54
  csrf_ok = respond_to?(:protect_against_forgery?) && protect_against_forgery?
55
55
  %>
56
- <section id="theme" class="space-y-8" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
56
+ <section id="theme" class="section-target space-y-8"
57
57
  x-data="dsThemeEditor(<%= seed_colors.to_json %>)">
58
+ <%= render "studio/section_header", title: "Theme" %>
58
59
  <div class="flex flex-wrap items-start justify-between gap-3">
59
60
  <div class="space-y-1">
60
- <h2 class="type-h3 text-heading">Theme</h2>
61
61
  <p class="text-muted text-sm max-w-2xl">Every specimen on this page restyles from these 7 color roles.</p>
62
62
  </div>
63
63
  <form action="/admin/theme/regenerate" method="post" accept-charset="UTF-8" @submit.prevent="regenerate($event)">