studio-engine 0.28.0 → 0.29.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: 79aac649d7403deadeaa9f055762802f111b7082865398432753dfdd93b998e0
4
- data.tar.gz: 80307a1b7077886c9853ad5a6ab7f2dc79a9d7bf6d4db7aa883f5eb6162a77bb
3
+ metadata.gz: b93ed19a9ea116acfe2d15bfcdd7cabc9abcf6ae2de9a63bfdcfc6d412d60e73
4
+ data.tar.gz: 9a98b2c22d817cddc642df050e1f9ecc34dfd87d9d87e4730b735774fec579b1
5
5
  SHA512:
6
- metadata.gz: 8fb8f96918656e88d2d8cc3379716d544afd461bdaa19205c480e273ab71199c25cede8c7b729f9b6cf3400388a7976ac34f4135d96fd60b3e8e4083969f360e
7
- data.tar.gz: 2f329ab8c0c240cf56e11e2e9d7bc1c3c6711e24dcb52ee82a2de2785c1697cf333e3bcc17f056db22400e4e606d6c30108565502bd8a6119e2d7b50e18a1a64
6
+ metadata.gz: 6d6cc744ebba89724d9811785d79a954d43b9cd9827d8fc5f86780cd465312668215b31a989cbb1a57c973ad74507600d7b4ae9179c912780c5beffa073c2f76
7
+ data.tar.gz: 39cace3ee05070698a8f06cf1c4463e03cf8bd8fdc97d2ad6b1b5d158a87fc23425e8618f09335034e5dfb04a70a5529b00c9a9743cedd001d6e9f6fe57204a4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.29.0 — 2026-07-29
6
+
7
+ **The four depth-chart gaps folded into the `studio/board` primitive (Phase D), so
8
+ the MS depth chart can rebase onto it like `/tasks`, news, and content did.** The
9
+ 0.28.0 primitive fit the near-identical kanban boards; the depth chart is more
10
+ custom — it ranks by a `depth` column (1 = starter) while its `position` column
11
+ holds a lane-code string, lays its lanes out in a two-level side→position grid, and
12
+ pins locked starters. Each capability is **additive and opt-in — every hook omitted
13
+ renders and ranks exactly as 0.28.0**, so `/tasks`, news, and content are untouched.
14
+
15
+ ### Added
16
+
17
+ - **DG1 — configurable rank column + sort direction** (`Studio::Board::Rankable`):
18
+ `board_rank_attr` (default `:position`) and `board_rank_order` (default `:desc`)
19
+ thread through `board_ordered`, `board_next_position`, `set_initial_position`, and
20
+ `reposition!`. A depth chart sets `board_rank_attr = :depth`, `board_rank_order =
21
+ :asc` (depth 1 on top). The order fragment quotes the column and whitelists the
22
+ ASC/DESC literal — never user input.
23
+ - **DG2 — sequential 1..N ranks**: `reposition!(ids, gap: 1, direction: :asc)` stamps
24
+ `1, 2, 3, … N` (the depth numbers) instead of the 100-gapped default.
25
+ - **DG3 — two-level side→position grid**: `studio/board/_board` accepts `groups:`
26
+ (`[{ key:, label:, cols_class:, columns: [...] }]`) and renders each group as a
27
+ labelled section whose lanes lay out in a responsive grid; `studio/board/_column`
28
+ gains `layout: "grid"` (drops the flex-row width utilities so the grid cell sizes
29
+ the column). Omit `groups:` for the unchanged flat row.
30
+ - **DG4 — per-card lock/pin**: `reposition!(skip_locked: true, lock_attr: :locked)`
31
+ leaves a pinned entry's rank untouched; `Studio::Board::Reorderable` gains the
32
+ matching `board_reorderable(rank_attr:, skip_locked:, lock_attr:)` config plus a
33
+ `board_toggle_lock` action (config-gated, `rescue_and_log(target:)` discipline,
34
+ `{ ok:, locked: }`); `studio/board/_card_shell` accepts `locked: true` (adds
35
+ `.kanban-locked` + `data-locked`); the `studioBoard` factory takes a
36
+ `lockedSelector` opt that filters locked cards from dragging and refuses a move
37
+ that would cross one, so a pinned starter keeps its slot while the rest reflow.
38
+ - **`/admin/style`** gains a third live board specimen — the depth-chart shape:
39
+ Offense/Defense sections, position lanes in a grid, within-lane reorder, and 🔒
40
+ pinned starters.
41
+
5
42
  ## 0.27.0 — 2026-07-28
6
43
 
7
44
  **Two coordinated modal flows on the `/admin/style` Design System page — a walked
@@ -28,21 +28,31 @@ module Studio
28
28
  extend ActiveSupport::Concern
29
29
 
30
30
  included do
31
- class_attribute :board_reorder_model, instance_accessor: false
32
- class_attribute :board_reorder_id_attr, instance_accessor: false, default: :slug
33
- class_attribute :board_reorder_param, instance_accessor: false, default: :slugs
34
- class_attribute :board_reorder_gap, instance_accessor: false, default: 100
35
- class_attribute :board_reorder_direction, instance_accessor: false, default: :desc
31
+ class_attribute :board_reorder_model, instance_accessor: false
32
+ class_attribute :board_reorder_id_attr, instance_accessor: false, default: :slug
33
+ class_attribute :board_reorder_param, instance_accessor: false, default: :slugs
34
+ class_attribute :board_reorder_gap, instance_accessor: false, default: 100
35
+ class_attribute :board_reorder_direction, instance_accessor: false, default: :desc
36
+ # DG1/DG2/DG4 — optional column + lock config. Defaults keep the exact 0.28.0
37
+ # kanban reorder: rank by the model's board_rank_attr, no lock skip. A depth
38
+ # chart passes gap: 1, direction: :asc (1..N depths) + skip_locked: true.
39
+ class_attribute :board_reorder_rank_attr, instance_accessor: false, default: nil
40
+ class_attribute :board_reorder_skip_locked, instance_accessor: false, default: false
41
+ class_attribute :board_reorder_lock_attr, instance_accessor: false, default: :locked
36
42
  end
37
43
 
38
44
  class_methods do
39
45
  # Configure the reorder action for this controller's board.
40
- def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc)
41
- self.board_reorder_model = model
42
- self.board_reorder_id_attr = id_attr
43
- self.board_reorder_param = param
44
- self.board_reorder_gap = gap
45
- self.board_reorder_direction = direction
46
+ def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc,
47
+ rank_attr: nil, skip_locked: false, lock_attr: :locked)
48
+ self.board_reorder_model = model
49
+ self.board_reorder_id_attr = id_attr
50
+ self.board_reorder_param = param
51
+ self.board_reorder_gap = gap
52
+ self.board_reorder_direction = direction
53
+ self.board_reorder_rank_attr = rank_attr
54
+ self.board_reorder_skip_locked = skip_locked
55
+ self.board_reorder_lock_attr = lock_attr
46
56
  end
47
57
  end
48
58
 
@@ -72,23 +82,59 @@ module Studio
72
82
  render json: { error: e.message }, status: :unprocessable_entity
73
83
  end
74
84
 
85
+ # POST — flip a card's lock flag (DG4). A locked entry is a pinned card the
86
+ # reorder skips (a confirmed depth-chart starter). Config-gated on the
87
+ # board_reorderable model + lock_attr; mirrors the MS depth-chart toggle_lock,
88
+ # INCLUDING its rescue_and_log(target: record) backend-write discipline, and
89
+ # the same respond_to? degrade the reorder action uses. Route it however the
90
+ # app names the endpoint (e.g. `post "…/:id/toggle_lock"`).
91
+ def board_toggle_lock
92
+ model = self.class.board_reorder_model
93
+ raise "board_reorderable model not configured" unless model
94
+
95
+ id_attr = self.class.board_reorder_id_attr
96
+ lock_attr = self.class.board_reorder_lock_attr
97
+ record = model.find_by(id_attr => params[:id])
98
+ return render(json: { error: "not found" }, status: :not_found) unless record
99
+
100
+ flip = -> { record.update!(lock_attr => !record.public_send(lock_attr)) }
101
+ if respond_to?(:rescue_and_log)
102
+ rescue_and_log(target: record) { flip.call }
103
+ else
104
+ flip.call
105
+ end
106
+ render json: { ok: true, locked: record.public_send(lock_attr) }
107
+ rescue StandardError => e
108
+ render json: { error: e.message }, status: :unprocessable_entity
109
+ end
110
+
75
111
  private
76
112
 
77
113
  # Delegate to the model's Rankable#reposition! when available (the shared
78
- # 100-gap rule), else fall back to the same per-id update_all loop inline so
79
- # a model that only carries a `position` column still reorders.
114
+ # rank rule), else fall back to the same per-id update_all loop inline so a
115
+ # model that only carries a rank column still reorders. Threads the DG1 rank
116
+ # column and DG4 lock-skip config through both paths.
80
117
  def board_reorder_apply(model, ids)
81
- id_attr = self.class.board_reorder_id_attr
82
- gap = self.class.board_reorder_gap
83
- direction = self.class.board_reorder_direction
118
+ id_attr = self.class.board_reorder_id_attr
119
+ gap = self.class.board_reorder_gap
120
+ direction = self.class.board_reorder_direction
121
+ rank_attr = self.class.board_reorder_rank_attr
122
+ skip_locked = self.class.board_reorder_skip_locked
123
+ lock_attr = self.class.board_reorder_lock_attr
84
124
 
85
125
  if model.respond_to?(:reposition!)
86
- model.reposition!(ids, gap: gap, direction: direction, id_attr: id_attr)
126
+ kwargs = { gap: gap, direction: direction, id_attr: id_attr,
127
+ skip_locked: skip_locked, lock_attr: lock_attr }
128
+ kwargs[:rank_attr] = rank_attr if rank_attr
129
+ model.reposition!(ids, **kwargs)
87
130
  else
131
+ rank_col = rank_attr || :position
88
132
  length = ids.length
89
133
  ids.each_with_index do |id, index|
90
134
  rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
91
- model.where(id_attr => id).update_all(position: rank)
135
+ rel = model.where(id_attr => id)
136
+ rel = rel.where.not(lock_attr => true) if skip_locked
137
+ rel.update_all(rank_col => rank)
92
138
  end
93
139
  end
94
140
  end
@@ -21,7 +21,9 @@ module Studio
21
21
  #
22
22
  # class DepthChartEntry < ApplicationRecord
23
23
  # include Studio::Board::Rankable
24
- # self.board_zone_attr = :position_group # a lane, not a kanban stage
24
+ # self.board_zone_attr = :position # a lane (QB/RB/…), not a kanban stage
25
+ # self.board_rank_attr = :depth # rank by depth; `position` is a string
26
+ # self.board_rank_order = :asc # depth 1 (the starter) sits on top
25
27
  # end
26
28
  module Rankable
27
29
  extend ActiveSupport::Concern
@@ -31,19 +33,37 @@ module Studio
31
33
  # boards (stage); a within-lane board overrides it, and `nil` ranks globally.
32
34
  class_attribute :board_zone_attr, instance_accessor: false, default: :stage
33
35
 
34
- # Board order: highest `position` first (freshest/top card wins), NULLS last,
35
- # then newest by created_at as a stable tiebreak. Arel.sql: the fragment is a
36
- # fixed literal, not user input.
37
- scope :board_ordered, -> { order(Arel.sql("position DESC NULLS LAST, created_at DESC")) }
36
+ # The column the board RANKS BY. Default :position the three MS kanban
37
+ # boards and the 100-gap read-model. A board whose rank lives in another
38
+ # column overrides it: the depth chart ranks by `depth` while its `position`
39
+ # column holds a lane-code STRING, so every ranking read/write below follows
40
+ # `board_rank_attr` rather than a hardcoded `position`.
41
+ class_attribute :board_rank_attr, instance_accessor: false, default: :position
42
+
43
+ # The board_ordered sort direction for the rank column. :desc (default) puts
44
+ # the HIGHEST rank on top — the kanban freshest-first shape; :asc puts the
45
+ # LOWEST on top so depth 1 (the starter) sits at the top of its lane.
46
+ class_attribute :board_rank_order, instance_accessor: false, default: :desc
47
+
48
+ # Board order: the rank column first (direction per board_rank_order), NULLS
49
+ # last, then newest by created_at as a stable tiebreak. The column name is a
50
+ # quoted identifier and the direction a whitelisted ASC/DESC literal — never
51
+ # user input — so the Arel.sql fragment is injection-safe.
52
+ scope :board_ordered, lambda {
53
+ dir = board_rank_order.to_s.casecmp("asc").zero? ? "ASC" : "DESC"
54
+ col = connection.quote_column_name(board_rank_attr)
55
+ order(Arel.sql("#{col} #{dir} NULLS LAST, created_at DESC"))
56
+ }
38
57
  end
39
58
 
40
- # Seed the genesis rank on create: max(position) within this record's zone plus
41
- # a 100 gap. A no-op when `position` is already set, so an explicit rank is
59
+ # Seed the genesis rank on create: max(rank column) within this record's zone
60
+ # plus a 100 gap. A no-op when the rank is already set, so an explicit rank is
42
61
  # never clobbered. Wire from the host via `before_create :set_initial_position`.
43
62
  def set_initial_position
44
- return if position.present?
63
+ rank_attr = self.class.board_rank_attr
64
+ return if public_send(rank_attr).present?
45
65
 
46
- self.position = self.class.board_next_position(zone_value_for_rank)
66
+ public_send("#{rank_attr}=", self.class.board_next_position(zone_value_for_rank))
47
67
  end
48
68
 
49
69
  # This record's zone value (nil when the model ranks globally).
@@ -58,7 +78,7 @@ module Studio
58
78
  # from here, so the 100-gap rule lives in one place.
59
79
  def board_next_position(zone_value = nil, gap: 100)
60
80
  scope = board_zone_attr && zone_value ? where(board_zone_attr => zone_value) : all
61
- (scope.maximum(:position) || 0) + gap
81
+ (scope.maximum(board_rank_attr) || 0) + gap
62
82
  end
63
83
 
64
84
  # Restamp a column top-to-bottom in ONE pass. `ids` arrive in DOM order (top
@@ -69,12 +89,23 @@ module Studio
69
89
  # (first id smallest) for an ascending board. This is exactly the per-id
70
90
  # `update_all(position: ...)` loop the MS tasks/news/content reorder actions
71
91
  # each ran by hand, lifted into the model. Returns the ids it stamped.
72
- def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key)
92
+ #
93
+ # DG2 — SEQUENTIAL 1..N RANKS: `gap: 1, direction: :asc` stamps 1, 2, 3, … N
94
+ # (the depth chart's depth numbers, 1 = starter), not the 100-gapped default.
95
+ # DG1 — a board that ranks by another column passes `rank_attr:` (defaults to
96
+ # the model's `board_rank_attr`, so a configured model needs nothing here).
97
+ # DG4 — `skip_locked: true` leaves a PINNED entry untouched: its rank column is
98
+ # not written, so a locked starter keeps its depth while the others reflow
99
+ # around it. The lock flag column is `lock_attr` (default :locked).
100
+ def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key,
101
+ rank_attr: board_rank_attr, skip_locked: false, lock_attr: :locked)
73
102
  ids = Array(ids)
74
103
  length = ids.length
75
104
  ids.each_with_index do |id, index|
76
105
  rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
77
- where(id_attr => id).update_all(position: rank)
106
+ rel = where(id_attr => id)
107
+ rel = rel.where.not(lock_attr => true) if skip_locked
108
+ rel.update_all(rank_attr => rank)
78
109
  end
79
110
  ids
80
111
  end
@@ -51,6 +51,10 @@
51
51
  // gap 3: the column key a data-exit-action="archive" remove re-parents INTO
52
52
  // (its #dropzone-<key>) rather than deleting the card. null ⇒ 0.27.0 removal.
53
53
  archiveZone: opts.archiveZone || null,
54
+ // DG4: selector for PINNED cards (a locked depth-chart starter). When set,
55
+ // locked cards are filtered from dragging AND a drag can't cross one, so a
56
+ // pinned card keeps its slot while the rest reflow. null ⇒ every card drags.
57
+ lockedSelector: opts.lockedSelector || null,
54
58
 
55
59
  // --- state ---------------------------------------------------------
56
60
  toasts: [],
@@ -104,7 +108,9 @@
104
108
  animation: 150,
105
109
  ghostClass: "opacity-30",
106
110
  draggable: self.cardSelector,
107
- filter: self.sortFilter,
111
+ // DG4: locked cards join the undraggable filter so a pinned entry
112
+ // can't be picked up (empty state was already filtered).
113
+ filter: self.lockedSelector ? (self.sortFilter + ", " + self.lockedSelector) : self.sortFilter,
108
114
  onStart: function () { window.__studioBoardDragging = true; },
109
115
  onEnd: function (evt) {
110
116
  self.handleSortEnd(evt);
@@ -113,6 +119,13 @@
113
119
  };
114
120
  if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
115
121
  if (self.handle) cfg.handle = self.handle;
122
+ if (self.lockedSelector) {
123
+ // DG4: pin locked cards — refuse a move that would cross a locked
124
+ // sibling, so a dragged card can't leapfrog a pinned starter.
125
+ cfg.onMove = function (evt) {
126
+ return !(evt.related && evt.related.matches && evt.related.matches(self.lockedSelector));
127
+ };
128
+ }
116
129
  Sortable.create(zone, cfg);
117
130
  });
118
131
  },
@@ -33,13 +33,29 @@
33
33
  4. An Archived column that a stage→archived broadcast should re-parent INTO
34
34
  (not delete) is named by the board's `archive_zone:`.
35
35
 
36
+ TWO-LEVEL GRID (DG3, the depth-chart shape) — a board whose columns group into
37
+ labelled SECTIONS laid out as a grid (offense/defense/special_teams, each a grid
38
+ of position lanes) passes `groups:` instead of a flat `columns:` row. Each group
39
+ renders a header + a responsive grid of its columns (the same studio/board/column,
40
+ layout: "grid"). Omit `groups:` and the board renders the 0.28.0 flat row unchanged.
41
+
36
42
  locals:
37
- columns: (req) [{ key:, label:, cards:, count:, badge_class:,
38
- count_class:, collapsible:, hidden:, show_expr:, min_width:,
39
- kickoff:, header_extra: }]. `cards` are records passed to
40
- `card_partial`; `count` defaults to cards.size. See
43
+ columns: (req unless `groups:`) [{ key:, label:, cards:, count:,
44
+ badge_class:, count_class:, collapsible:, hidden:, show_expr:,
45
+ min_width:, kickoff:, header_extra: }]. `cards` are records
46
+ passed to `card_partial`; `count` defaults to cards.size. See
41
47
  studio/board/_column for the additive per-column hooks
42
48
  (count_class / show_expr / min_width / kickoff).
49
+ groups: DG3 — [{ key:, label:, cols_class:, columns: [<column hash>] }]
50
+ renders a two-level side→position GRID: each group is a labelled
51
+ section whose `columns` lay out in a grid (`cols_class` overrides
52
+ the responsive default). Takes precedence over the flat `columns`
53
+ row; the factory's labels merge across every group's columns.
54
+ locked_selector: DG4 — CSS selector for PINNED cards (default ".kanban-locked",
55
+ emitted by studio/board/_card_shell `locked: true`). When set,
56
+ the studioBoard factory filters those cards from dragging and
57
+ won't let a drag cross them, so a locked starter stays put. nil
58
+ keeps every card draggable (0.28.0).
43
59
  card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
44
60
  card_as: local the record binds to in card_partial. Default :card.
45
61
  card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
@@ -78,7 +94,11 @@
78
94
  demo: style-guide preview — drag works locally, no network POST.
79
95
  %>
80
96
  <%
81
- columns = local_assigns.fetch(:columns)
97
+ groups = local_assigns.fetch(:groups, nil) # DG3: two-level side→position grid
98
+ columns = local_assigns.fetch(:columns, nil)
99
+ # The factory's label map + the flat fallback both need every column; a grouped
100
+ # board flattens its groups' columns, a flat board uses `columns` directly.
101
+ all_columns = groups ? groups.flat_map { |g| Array(g[:columns]) } : (columns || [])
82
102
  card_partial = local_assigns.fetch(:card_partial)
83
103
  card_as = local_assigns.fetch(:card_as, :card)
84
104
  card_locals = local_assigns.fetch(:card_locals, nil)
@@ -103,6 +123,7 @@
103
123
  above_board_slot = local_assigns.fetch(:above_board_slot, nil)
104
124
  board_state = local_assigns.fetch(:board_state, nil) # gap 1: chrome-state home
105
125
  archive_zone = local_assigns.fetch(:archive_zone, nil) # gap 3: exit re-parent target
126
+ locked_selector = local_assigns.fetch(:locked_selector, nil) # DG4: pinned-card selector
106
127
  fx = local_assigns.fetch(:fx, nil)
107
128
  on_move_hook = local_assigns.fetch(:on_move_hook, nil)
108
129
  on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
@@ -128,10 +149,11 @@
128
149
  demo: !!demo,
129
150
  state: (board_state.is_a?(Hash) ? board_state : nil),
130
151
  archiveZone: (archive_zone ? archive_zone.to_s : nil),
152
+ lockedSelector: (locked_selector ? locked_selector.to_s : nil),
131
153
  fx: fx,
132
154
  onMoveHook: on_move_hook,
133
155
  onDropHook: on_drop_hook,
134
- labels: columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
156
+ labels: all_columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
135
157
  }
136
158
  %>
137
159
  <section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
@@ -140,20 +162,49 @@
140
162
  <% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
141
163
  <% if above_board_slot %><%= above_board_slot %><% end %>
142
164
 
143
- <div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
144
- <% columns.each do |column| %>
145
- <%= render "studio/board/column",
146
- column: column,
147
- zone_attr: zone_attr,
148
- dom_id_prefix: dom_id_prefix,
149
- zone_selector_class: zone_selector.delete_prefix("."),
150
- empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
151
- empty_label: empty_label,
152
- card_partial: card_partial,
153
- card_as: card_as,
154
- card_locals: card_locals %>
155
- <% end %>
156
- </div>
165
+ <% if groups %>
166
+ <%# DG3: two-level side→position grid — each group a labelled section, its
167
+ columns in a responsive grid (the same studio/board/column, layout: grid). %>
168
+ <div class="studio-board-grid space-y-8">
169
+ <% groups.each do |group| %>
170
+ <section class="studio-board-group" data-board-group="<%= group[:key] %>">
171
+ <% if group[:label] %>
172
+ <h2 class="mb-3 text-xs font-bold uppercase tracking-widest text-secondary"><%= group[:label] %></h2>
173
+ <% end %>
174
+ <div class="grid gap-4 <%= group[:cols_class] || "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" %>">
175
+ <% Array(group[:columns]).each do |column| %>
176
+ <%= render "studio/board/column",
177
+ column: column,
178
+ layout: "grid",
179
+ zone_attr: zone_attr,
180
+ dom_id_prefix: dom_id_prefix,
181
+ zone_selector_class: zone_selector.delete_prefix("."),
182
+ empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
183
+ empty_label: empty_label,
184
+ card_partial: card_partial,
185
+ card_as: card_as,
186
+ card_locals: card_locals %>
187
+ <% end %>
188
+ </div>
189
+ </section>
190
+ <% end %>
191
+ </div>
192
+ <% else %>
193
+ <div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
194
+ <% (columns || []).each do |column| %>
195
+ <%= render "studio/board/column",
196
+ column: column,
197
+ zone_attr: zone_attr,
198
+ dom_id_prefix: dom_id_prefix,
199
+ zone_selector_class: zone_selector.delete_prefix("."),
200
+ empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
201
+ empty_label: empty_label,
202
+ card_partial: card_partial,
203
+ card_as: card_as,
204
+ card_locals: card_locals %>
205
+ <% end %>
206
+ </div>
207
+ <% end %>
157
208
 
158
209
  <% if toasts %>
159
210
  <div class="studio-board-toasts fixed top-4 right-4 z-50 space-y-2" data-test="studio-board-toasts">
@@ -24,6 +24,11 @@
24
24
  card_class: the required base class. Default "kanban-card".
25
25
  class: extra classes appended after card_class.
26
26
  style: inline style string.
27
+ locked: DG4 — a PINNED card the reorder skips (a confirmed depth-chart
28
+ starter). Adds the `.kanban-locked` class the studioBoard factory
29
+ filters from dragging + the `data-locked="true"` marker; false
30
+ (default) renders identically to before. Purely a hook — the lock
31
+ glyph/visual lives in the app's card body.
27
32
  attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
28
33
  values are HTML-escaped — the rebase seam for a card that carries
29
34
  its own Alpine/data-* on the root.
@@ -37,11 +42,14 @@
37
42
  card_class = local_assigns.fetch(:card_class, "kanban-card")
38
43
  extra_class = local_assigns.fetch(:class, nil)
39
44
  inline_style = local_assigns.fetch(:style, nil)
45
+ locked = local_assigns.fetch(:locked, false)
40
46
  root_attrs = local_assigns.fetch(:attrs, {}) || {}
47
+ classes = [card_class, ("kanban-locked" if locked), extra_class].compact.join(" ")
41
48
  %>
42
49
  <div id="<%= dom_id_prefix %><%= id %>"
43
- class="<%= [card_class, extra_class].compact.join(" ") %>"
50
+ class="<%= classes %>"
44
51
  data-<%= id_attr %>="<%= id %>"
45
52
  data-<%= zone_attr %>="<%= zone %>"
53
+ <% if locked %>data-locked="true"<% end %>
46
54
  <% root_attrs.each do |name, value| %><%= name %>="<%= value %>" <% end %>
47
55
  <% if inline_style %>style="<%= inline_style %>"<% end %>><%= yield %></div>
@@ -31,6 +31,10 @@
31
31
  kickoff: raw HTML rendered in a fixed-height slot
32
32
  under the header row (a per-column kickoff chip);
33
33
  nil renders nothing.
34
+ layout: DG3 — "row" (default) sizes the column for the horizontal
35
+ flex row (flex-1 + min_width); "grid" drops those so a
36
+ two-level side→position GRID cell owns the sizing. Any
37
+ other value behaves as "row".
34
38
  zone_attr: data-attr name for the zone. Default "stage".
35
39
  dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
36
40
  zone_selector_class: the drop-zone class. Default "kanban-dropzone".
@@ -42,6 +46,7 @@
42
46
  %>
43
47
  <%
44
48
  column = local_assigns.fetch(:column)
49
+ layout = local_assigns.fetch(:layout, "row").to_s
45
50
  zone_attr = local_assigns.fetch(:zone_attr, "stage")
46
51
  dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
47
52
  zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
@@ -70,6 +75,9 @@
70
75
  # The neutral count chip's colour classes: an opt-in raw class, else the 0.27.0
71
76
  # neutral default. The layout classes are shared; only the colour band varies.
72
77
  count_color = count_class || "bg-surface-alt text-secondary border border-subtle"
78
+ # DG3: in a grid the cell owns the sizing, so drop the flex-row width utilities;
79
+ # "row" (default) keeps the exact 0.28.0 flex-1 + min-width column.
80
+ width_class = layout == "grid" ? "w-full" : "w-full flex-none sm:flex-1 #{min_width}"
73
81
 
74
82
  # Merge the per-card hook (Proc or Hash) with the record binding.
75
83
  resolve_locals = lambda do |record|
@@ -77,7 +85,7 @@
77
85
  { card_as => record }.merge(extra || {})
78
86
  end
79
87
  %>
80
- <div class="studio-board-column w-full flex-none sm:flex-1 <%= min_width %>"
88
+ <div class="studio-board-column <%= width_class %>"
81
89
  data-board-column="<%= key %>"
82
90
  <% if collapsible %>data-board-collapsible="true"<% end %>
83
91
  <% if show_expr %>x-show="<%= show_expr %>" x-cloak<% end %>
@@ -172,6 +172,54 @@
172
172
  empty_label: "Drop tasks here" %>
173
173
  </div>
174
174
 
175
+ <%# ---- The DEPTH-CHART shape (0.29.0 / DG) ----------------------------- %>
176
+ <%# The two-level side→position GRID + per-card lock the depth chart needs, all
177
+ additive: groups: renders labelled sections (Offense/Defense) whose position
178
+ lanes lay out in a grid (not the flat row); group: false keeps a drag WITHIN
179
+ its lane; zone_attr "position" is the lane; 🔒 starters carry `locked: true`
180
+ so the factory filters them from dragging (locked_selector) and a drag can't
181
+ cross them. reorder is 1..N (depth) — demonstrated statically here (demo). %>
182
+ <%
183
+ dc_groups = [
184
+ { key: "offense", label: "Offense", cols_class: "grid-cols-2 sm:grid-cols-3",
185
+ columns: [
186
+ { key: "QB", label: "QB", cards: [
187
+ { id: "dc-qb1", zone: "QB", title: "Starter QB", repo: "depth 1", who: "1", locked: true },
188
+ { id: "dc-qb2", zone: "QB", title: "Backup QB", repo: "depth 2", who: "2" },
189
+ { id: "dc-qb3", zone: "QB", title: "3rd string", repo: "depth 3", who: "3" } ] },
190
+ { key: "RB", label: "RB", cards: [
191
+ { id: "dc-rb1", zone: "RB", title: "RB1", repo: "depth 1", who: "1" },
192
+ { id: "dc-rb2", zone: "RB", title: "RB2", repo: "depth 2", who: "2" } ] },
193
+ { key: "WR", label: "WR", cards: [
194
+ { id: "dc-wr1", zone: "WR", title: "WR1", repo: "depth 1", who: "1" } ] } ] },
195
+ { key: "defense", label: "Defense", cols_class: "grid-cols-2 sm:grid-cols-3",
196
+ columns: [
197
+ { key: "EDGE", label: "EDGE", cards: [
198
+ { id: "dc-edge1", zone: "EDGE", title: "EDGE1", repo: "depth 1", who: "1" },
199
+ { id: "dc-edge2", zone: "EDGE", title: "EDGE2", repo: "depth 2", who: "2" } ] },
200
+ { key: "CB", label: "CB", cards: [
201
+ { id: "dc-cb1", zone: "CB", title: "Starter CB", repo: "depth 1", who: "1", locked: true },
202
+ { id: "dc-cb2", zone: "CB", title: "CB2", repo: "depth 2", who: "2" } ] } ] }
203
+ ]
204
+ %>
205
+ <div class="space-y-3">
206
+ <div class="flex items-center gap-2">
207
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Grid + Lock</span>
208
+ <p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> &middot; DG (0.29.0) &mdash; two-level side&rarr;position grid, reorder within a lane, &#128274; pinned starters stay put</p>
209
+ </div>
210
+ <%= render "studio/board/board",
211
+ demo: true,
212
+ groups: dc_groups,
213
+ card_partial: "style/board/demo_card",
214
+ card_as: :card,
215
+ reorder_url: "#",
216
+ group: false,
217
+ zone_attr: "position",
218
+ locked_selector: ".kanban-locked",
219
+ card_locals: { zone_attr: "position" },
220
+ empty_label: "&mdash;".html_safe %>
221
+ </div>
222
+
175
223
  <%# ---- The static reference sketch ------------------------------------- %>
176
224
  <div class="fade-edge-x overflow-x-auto pb-2">
177
225
  <div class="flex gap-4 min-w-max">
@@ -7,11 +7,15 @@
7
7
  id_attr / zone_attr flow through from the board so the same partial demonstrates
8
8
  the primitive under both the default and a custom attribute naming.
9
9
  %>
10
+ <%# A locked card (DG4) flows `locked: true` to the card-shell — it gains the
11
+ .kanban-locked class the factory filters from dragging, and shows a 🔒 in place
12
+ of the drag glyph so the pinned state reads at a glance. %>
10
13
  <%= render layout: "studio/board/card_shell",
11
14
  locals: { id: card[:id], zone: card[:zone], id_attr: id_attr, zone_attr: zone_attr,
12
- class: "card p-3 space-y-2 cursor-grab active:cursor-grabbing" } do %>
15
+ locked: card[:locked],
16
+ class: "card p-3 space-y-2 #{card[:locked] ? "cursor-default opacity-90" : "cursor-grab active:cursor-grabbing"}" } do %>
13
17
  <div class="flex items-start gap-2">
14
- <span class="text-muted select-none leading-none" aria-hidden="true">⠿</span>
18
+ <span class="text-muted select-none leading-none" aria-hidden="true"><%= card[:locked] ? "🔒" : "⠿" %></span>
15
19
  <p class="text-sm font-semibold text-heading leading-snug flex-1"><%= card[:title] %></p>
16
20
  </div>
17
21
  <div class="flex items-center justify-between gap-2">
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.28.0"
2
+ VERSION = "0.29.0"
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.28.0
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie