bacon-tracker 1.0.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.
@@ -0,0 +1,465 @@
1
+ module BaconTracker
2
+ module Tasks
3
+ extend Rake::DSL
4
+
5
+ # Parse the server port from $PORT, failing loudly on a non-integer instead
6
+ # of String#to_i silently yielding 0 and binding a random port (BT-087).
7
+ def self.env_port
8
+ Integer(ENV.fetch('PORT', '4567'))
9
+ rescue ArgumentError
10
+ abort "PORT must be an integer, got: #{ENV['PORT'].inspect}"
11
+ end
12
+
13
+ # 1-indexed line number of the first line matching `re`, or nil. Used to
14
+ # anchor a GitHub annotation to the offending frontmatter line rather than
15
+ # to the top of the file; nil is fine, the annotation just lands file-wide.
16
+ def self.line_matching(path, re)
17
+ return nil unless path && File.file?(path)
18
+
19
+ File.foreach(path).with_index(1) { |line, n| return n if line.match?(re) }
20
+ nil
21
+ rescue SystemCallError
22
+ nil
23
+ end
24
+
25
+ # A GitHub Actions workflow command, so a finding renders as an annotation
26
+ # on the file that caused it. Errors fail the check; flow signals are
27
+ # notices, which annotate without failing - the same severity split the
28
+ # terminal output makes (BT-121/BT-123).
29
+ #
30
+ # Paths must be workspace-relative or GitHub will not match them to the
31
+ # diff. The message and property values are escaped per GitHub's rules:
32
+ # messages escape %/CR/LF, properties additionally escape , and :.
33
+ def self.github_annotation(finding)
34
+ esc = ->(s) { s.to_s.gsub('%', '%25').gsub("\r", '%0D').gsub("\n", '%0A') }
35
+ prop = ->(s) { esc.call(s).gsub(',', '%2C').gsub(':', '%3A') }
36
+
37
+ props = []
38
+ if (rel = relative_path(finding[:path]))
39
+ props << "file=#{prop.call(rel)}"
40
+ props << "line=#{finding[:line]}" if finding[:line]
41
+ end
42
+ command = finding[:severity] == :notice ? 'notice' : 'error'
43
+ "::#{command}#{props.empty? ? '' : " #{props.join(',')}"}::#{esc.call(finding[:text])}"
44
+ end
45
+
46
+ # Path relative to the workspace GitHub checked out (falling back to the
47
+ # working directory), since annotations are matched against repo-relative
48
+ # paths. Returns nil for a path outside it - better no `file=` than a wrong
49
+ # one, which would annotate an unrelated file.
50
+ def self.relative_path(path)
51
+ return nil unless path
52
+
53
+ root = File.expand_path(ENV.fetch('GITHUB_WORKSPACE', Dir.pwd))
54
+ full = File.expand_path(path)
55
+ return nil unless full.start_with?("#{root}/")
56
+
57
+ full.delete_prefix("#{root}/")
58
+ end
59
+
60
+ # One line per relationship finding (BT-121). Each says what is wrong and,
61
+ # where there is an obvious next move, what to do about it - a lint line
62
+ # nobody knows how to act on is a lint line people learn to skip.
63
+ def self.relationship_message(finding)
64
+ id, ref = finding[:id], finding[:ref]
65
+ case finding[:kind]
66
+ when :stale_blocker
67
+ "stale-blocker: #{id} waits on #{ref}, which is done - clear it from blocked_by"
68
+ when :dangling
69
+ "dangling-ref: #{id} #{finding[:field]} names #{ref}, which has no story file"
70
+ when :cycle
71
+ chain = (finding[:cycle] + [finding[:cycle].first]).join(' → ')
72
+ "cycle: #{chain} - none of these can ever start"
73
+ when :started_while_blocked
74
+ "blocked-started: #{id} is started but waits on #{ref} (#{finding[:ref_stage]})"
75
+ end
76
+ end
77
+
78
+ def self.install_for_dashboard(dashboard_path)
79
+ require 'bacon_tracker/dashboard'
80
+
81
+ dashboard = BaconTracker::Dashboard.new(dashboard_path)
82
+ ns = ENV['NS'] || ENV['NAMESPACE']
83
+
84
+ # Without NS the per-project story tasks can't be targeted, but rake
85
+ # itself must keep working (rake -T, story:dashboard_server) - aborting
86
+ # here would kill every rake invocation at Rakefile-load time.
87
+ unless ns
88
+ install_dashboard_server_task(dashboard_path)
89
+ return
90
+ end
91
+
92
+ proj = dashboard.projects.find { |p| p.namespace.casecmp(ns) == 0 }
93
+ unless proj
94
+ list = dashboard.projects.map { |p| "#{p.namespace} (#{p.name})" }.join(', ')
95
+ abort "No project with namespace '#{ns}'.\n Available: #{list}"
96
+ end
97
+
98
+ # Same Configuration the server builds for this project - tracker, docs
99
+ # and decisions roots included - so `rake story:*` and the board always
100
+ # read and write the same directories (BT-179).
101
+ BaconTracker.instance_variable_set(:@config, dashboard.config_for(proj))
102
+
103
+ install
104
+ install_dashboard_server_task(dashboard_path) # install's copy has no path
105
+ end
106
+
107
+ def self.install_version_tasks
108
+ version_file = File.expand_path('../bacon_tracker/version.rb', __dir__)
109
+
110
+ # Same replace-don't-stack rule as install (BT-057) - a doubled bump
111
+ # task would bump the version twice per invocation.
112
+ Rake.application.tasks.each { |t| t.clear if t.name.start_with?('version:') }
113
+
114
+ namespace :version do
115
+ desc 'Print the current gem version'
116
+ task :current do
117
+ require 'bacon_tracker/version'
118
+ puts BaconTracker::VERSION
119
+ end
120
+
121
+ %w[patch minor major].each do |level|
122
+ desc "Bump #{level} version in lib/bacon_tracker/version.rb"
123
+ task level do
124
+ content = File.read(version_file)
125
+ current = content[/VERSION = "(\d+\.\d+\.\d+)"/, 1]
126
+ abort "Could not read VERSION from #{version_file}" unless current
127
+ parts = current.split('.').map(&:to_i)
128
+ case level
129
+ when 'patch' then parts[2] += 1
130
+ when 'minor' then parts[1] += 1; parts[2] = 0
131
+ when 'major' then parts[0] += 1; parts[1] = 0; parts[2] = 0
132
+ end
133
+ new_version = parts.join('.')
134
+ File.write(version_file, content.sub(/VERSION = "#{Regexp.escape(current)}"/, %(VERSION = "#{new_version}")))
135
+ puts "#{current} → #{new_version}"
136
+ end
137
+ end
138
+
139
+ desc 'Tag HEAD as the current version and push the tag'
140
+ task :release do
141
+ require 'bacon_tracker/version'
142
+ tag = "v#{BaconTracker::VERSION}"
143
+ # Refuse to tag a dirty tree: BaconTracker::VERSION is read from the
144
+ # working copy, so an uncommitted `version:patch` would tag a HEAD
145
+ # whose committed version.rb still says the old number (BT-110).
146
+ unless `git status --porcelain`.strip.empty?
147
+ abort "Working tree is dirty - commit the version bump before releasing #{tag}."
148
+ end
149
+ system('git', 'tag', tag) || abort('git tag failed')
150
+ system('git', 'push', 'origin', tag) || abort('git push failed')
151
+ puts "Released #{tag}"
152
+ end
153
+ end
154
+ end
155
+
156
+ def self.install
157
+ # Re-install replaces earlier story tasks - repeated installs used to
158
+ # stack duplicate actions (Rake appends on redefinition). The config is
159
+ # snapshotted so later configure calls can't retarget installed tasks.
160
+ Rake.application.tasks.each { |t| t.clear if t.name.start_with?('story:', 'decision:') }
161
+ core = BaconTracker::Core.new(BaconTracker.config.dup)
162
+ ns = core.config.namespace
163
+
164
+ namespace :decision do
165
+ desc "Create a proposed decision from the template: rake \"decision:new[My decision]\""
166
+ task :new, [:title] do |_, args|
167
+ path = core.create_decision(args[:title])
168
+ puts "#{File.basename(path, '.md')[/\A[A-Z][A-Z0-9]*-ADR-\d+/]} → #{path}"
169
+ rescue ArgumentError => e
170
+ abort e.message
171
+ end
172
+
173
+ desc "Accept a proposed decision: rake \"decision:accept[#{ns}-ADR-0001]\""
174
+ task :accept, [:id] do |_, args|
175
+ core.transition_decision(args[:id], 'accepted')
176
+ end
177
+
178
+ desc "Reject a proposed decision: rake \"decision:reject[#{ns}-ADR-0001]\""
179
+ task :reject, [:id] do |_, args|
180
+ core.transition_decision(args[:id], 'rejected')
181
+ end
182
+
183
+ desc "Deprecate an accepted decision: rake \"decision:deprecate[#{ns}-ADR-0001]\""
184
+ task :deprecate, [:id] do |_, args|
185
+ core.transition_decision(args[:id], 'deprecated')
186
+ end
187
+
188
+ desc "Supersede an accepted decision - the second argument is required: rake \"decision:supersede[#{ns}-ADR-0001,#{ns}-ADR-0002]\""
189
+ task :supersede, [:id, :by] do |_, args|
190
+ core.transition_decision(args[:id], 'superseded', superseded_by: args[:by])
191
+ end
192
+
193
+ desc 'Check the decision records: frontmatter, status vs directory, ids, supersession, proposed.md'
194
+ task :lint do
195
+ unless core.config.decisions_root
196
+ puts 'No decisions_root configured - nothing to lint.'
197
+ next
198
+ end
199
+
200
+ findings = core.decision_findings
201
+ failures = findings.select { |f| f[:severity] == :failure }
202
+ warnings = findings.select { |f| f[:severity] == :warning }
203
+
204
+ if findings.empty?
205
+ puts "Decisions are clean - #{core.decisions.size} records."
206
+ next
207
+ end
208
+
209
+ failures.each { |f| puts " #{f[:kind]}: #{[f[:id], f[:message]].compact.join(' - ')}" }
210
+ warnings.each { |f| puts " warning #{f[:kind]}: #{[f[:id], f[:message]].compact.join(' - ')}" }
211
+ puts
212
+ puts "#{failures.size} failure(s), #{warnings.size} warning(s) over #{core.decisions.size} records."
213
+
214
+ # Severity split follows BT-ADR-0013: integrity fails the build,
215
+ # hygiene reports and exits clean.
216
+ abort 'Decision lint failed.' if failures.any?
217
+ end
218
+ end
219
+
220
+ namespace :story do
221
+ # Parse trailing field=value tokens (size/assignee/blocked_by/linked_to/title/body)
222
+ # into update_story kwargs, aborting cleanly on an unknown field. Shared
223
+ # by the create tasks and story:edit.
224
+ parse_fields = lambda do |extras|
225
+ core.edit_assignments(extras)
226
+ rescue ArgumentError => e
227
+ abort e.message
228
+ end
229
+
230
+ desc "Create a feature in features/1_icebox, optional fields: rake \"story:feature[My Title,size=M,assignee=AB]\""
231
+ task :feature, [:title] do |_, args|
232
+ abort "Usage: rake \"story:feature[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
233
+ core.create('feature', args[:title], parse_fields.call(args.extras))
234
+ end
235
+
236
+ desc "Create a bug in bugs/1_icebox, optional fields: rake \"story:bug[My Title,size=S]\""
237
+ task :bug, [:title] do |_, args|
238
+ abort "Usage: rake \"story:bug[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
239
+ core.create('bug', args[:title], parse_fields.call(args.extras))
240
+ end
241
+
242
+ desc "Create a chore in chores/1_icebox, optional fields: rake \"story:chore[My Title,assignee=AB]\""
243
+ task :chore, [:title] do |_, args|
244
+ abort "Usage: rake \"story:chore[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
245
+ core.create('chore', args[:title], parse_fields.call(args.extras))
246
+ end
247
+
248
+ desc "Move a story from 1_icebox to 2_backlog and add to backlog.md (commit to it): rake \"story:commit[#{ns}-001]\""
249
+ task :commit, [:story_id] do |_, args|
250
+ id = args[:story_id]&.strip
251
+ abort "Usage: rake \"story:commit[#{ns}-001]\"" if id.nil?
252
+ core.commit(id)
253
+ end
254
+
255
+ desc "Move a story from 2_backlog to 3_started (start work on it): rake \"story:start[#{ns}-001]\""
256
+ task :start, [:story_id] do |_, args|
257
+ id = args[:story_id]&.strip
258
+ abort "Usage: rake \"story:start[#{ns}-001]\"" if id.nil?
259
+ core.start(id)
260
+ end
261
+
262
+ desc "Move a story to 4_done and remove from backlog.md: rake \"story:done[#{ns}-001]\""
263
+ task :done, [:story_id] do |_, args|
264
+ id = args[:story_id]&.strip
265
+ abort "Usage: rake \"story:done[#{ns}-001]\"" if id.nil?
266
+ core.done(id)
267
+ end
268
+
269
+ desc "Set story fields (size/assignee/blocked_by/linked_to/title/body): rake \"story:edit[#{ns}-001,size=M,assignee=AB]\""
270
+ task :edit, [:story_id] do |_, args|
271
+ id = args[:story_id]&.strip
272
+ abort "Usage: rake \"story:edit[#{ns}-001,size=M,assignee=AB]\"" if id.nil?
273
+ kwargs = parse_fields.call(args.extras)
274
+ abort "No fields given. Example: rake \"story:edit[#{ns}-001,size=M]\"" if kwargs.empty?
275
+ warn "Note: body= replaces the entire body of #{id}." if kwargs.key?(:body)
276
+ begin
277
+ core.update_story(id, **kwargs)
278
+ puts "Updated #{id}: #{kwargs.keys.join(', ')}"
279
+ rescue ArgumentError => e
280
+ abort e.message
281
+ end
282
+ end
283
+
284
+ desc 'Print the top item in backlog.md'
285
+ task :next do
286
+ lines = core.backlog_lines
287
+ abort 'Backlog is empty.' if lines.empty?
288
+ puts lines.first.strip
289
+ end
290
+
291
+ desc 'Check backlog.md against 2_backlog story files for drift, flag duplicate IDs across stages, check blocked_by/linked_to, and verify .next-id'
292
+ task :lint do
293
+ listed = core.backlog_ids
294
+ on_disk = core.backlog_story_files.filter_map { |f| File.basename(f)[core.filename_id_pattern] }
295
+ phantom = listed - on_disk
296
+ unlisted = on_disk - listed
297
+ dupes = core.duplicate_id_stages
298
+ drift = core.status_drift
299
+ max_id = core.max_story_id
300
+ next_id = core.next_id_value
301
+ stale_id = next_id <= max_id
302
+
303
+ rel = core.relationship_findings
304
+ rel_warn, rel_err = rel.partition { |f| BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) }
305
+
306
+ # Flow signals (rel_warn) are reported but never fail the build: a
307
+ # started story waiting on a blocker is a legitimate state per
308
+ # docs/flow.md, so failing CI on it would train people to ignore lint.
309
+ broken = !(phantom.empty? && unlisted.empty? && dupes.empty? && drift.empty? && rel_err.empty?) || stale_id
310
+
311
+ if !broken && rel_warn.empty?
312
+ puts "Backlog is clean - #{listed.size} stories."
313
+ else
314
+ # One list, two renderings. Each entry carries the text (identical
315
+ # to what the terminal has always printed) plus where it came from,
316
+ # so LINT_FORMAT=github can anchor an annotation to the offending
317
+ # file and line instead of burying it in a log.
318
+ findings = []
319
+ add = ->(text, path, line = nil, severity = :error) do
320
+ findings << { text: text, path: path, line: line, severity: severity }
321
+ end
322
+
323
+ backlog = core.config.backlog_path
324
+ phantom.each do |id|
325
+ add.call("phantom: #{id} in backlog.md but no file in 2_backlog/",
326
+ backlog, BaconTracker::Tasks.line_matching(backlog, /\b#{Regexp.escape(id)}\b/))
327
+ end
328
+ unlisted.each do |id|
329
+ # Anchored to the story file, not backlog.md: the pull request
330
+ # that forgot the backlog line is the one that added this file,
331
+ # so this is the side that appears in the diff.
332
+ add.call("unlisted: #{id} has file in 2_backlog/ but missing from backlog.md",
333
+ core.backlog_story_files.find { |f| File.basename(f).start_with?(id) } || backlog)
334
+ end
335
+ dupes.each do |id, paths|
336
+ text = "duplicate: #{id} found in #{paths.map { |p| p.sub("#{core.config.tracker_root}/", '') }.join(' and ')}"
337
+ paths.each { |dup| add.call(text, dup) }
338
+ end
339
+ drift.each do |d|
340
+ path = d[:path]
341
+ add.call("status-drift: #{d[:id]} frontmatter says '#{d[:declared]}' but the file lives in the #{d[:actual]} stage dir",
342
+ path, BaconTracker::Tasks.line_matching(path, /^#?\s*status:/))
343
+ end
344
+ if stale_id
345
+ add.call("stale-id: .next-id is #{next_id} but the highest story is #{core.format_id(max_id)} - expected #{max_id + 1}",
346
+ core.config.next_id_path)
347
+ end
348
+ (rel_err + rel_warn).each do |f|
349
+ field = f[:kind] == :dangling ? f[:field] : 'blocked_by'
350
+ add.call(BaconTracker::Tasks.relationship_message(f), f[:path],
351
+ BaconTracker::Tasks.line_matching(f[:path], /^#?\s*#{field}:/),
352
+ BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) ? :notice : :error)
353
+ end
354
+
355
+ if ENV['LINT_FORMAT'] == 'github'
356
+ # Duplicates share one text across two files; each still gets its
357
+ # own annotation, but say it once per file, not once per pairing.
358
+ findings.uniq { |f| [f[:text], f[:path]] }
359
+ .each { |f| puts BaconTracker::Tasks.github_annotation(f) }
360
+ else
361
+ findings.uniq { |f| f[:text] }.each { |f| warn " #{f[:text]}" }
362
+ end
363
+ puts 'No integrity problems - the note above is a flow signal, not a failure.' if !broken && rel_warn.any?
364
+ exit 1 if broken
365
+ end
366
+ end
367
+
368
+ desc 'Start the tracker board on http://localhost:4567'
369
+ task :server do
370
+ require 'bacon_tracker/server'
371
+ port = BaconTracker::Tasks.env_port
372
+ puts "Bacon Tracker running at http://localhost:#{port}"
373
+ puts 'Ctrl-C to stop.'
374
+ BaconTracker::Server.boot(core.config).run!(port: port, bind: 'localhost', quiet: true)
375
+ end
376
+
377
+ desc "Assign #{ns} IDs to all existing stories (oldest git commit first)"
378
+ task :migrate do
379
+ first_commits = core.git_first_commit_times
380
+ all_files = core.story_dirs.flat_map do |d|
381
+ core.safe_glob(d[:path], '*.{md,feature}').filter_map do |f|
382
+ next if File.basename(f).start_with?('_')
383
+ next if core.already_migrated?(f)
384
+
385
+ content = File.read(f, encoding: 'utf-8')
386
+ # Skip a file that already carries a frontmatter block (but no ID
387
+ # in its name) - prepending again would double the frontmatter and
388
+ # bury the old block in the body (BT-105).
389
+ next if content.start_with?("---\n") || content.match?(/\A# \w+:/)
390
+
391
+ relative = f.sub("#{core.config.tracker_root}/", '')
392
+ d.merge(file: f, content: content, ts: first_commits[relative] || File.mtime(f).to_i)
393
+ end
394
+ end
395
+
396
+ all_files.sort_by! { |f| [f[:ts], f[:file]] }
397
+
398
+ if all_files.empty?
399
+ puts 'Nothing to migrate - all stories already have IDs.'
400
+ next
401
+ end
402
+
403
+ all_files.each do |info|
404
+ path = info[:file]
405
+ ext = File.extname(path)
406
+ base = File.basename(path, ext)
407
+ status = BaconTracker::STATUS_MAP[info[:stage]]
408
+ id = core.format_id(core.consume_id)
409
+
410
+ new_name = "#{id}-#{base}#{ext}"
411
+ new_path = File.join(File.dirname(path), new_name)
412
+
413
+ new_content = if ext == '.feature'
414
+ core.frontmatter_feature(id, info[:type], status) + info[:content]
415
+ else
416
+ core.frontmatter_md(id, info[:type], status) + info[:content]
417
+ end
418
+
419
+ File.write(new_path, new_content)
420
+ File.delete(path) unless path == new_path
421
+ puts " #{File.basename(path)} -> #{new_name}"
422
+ end
423
+
424
+ # Renamed 2_backlog files got new IDs - reconcile backlog.md so they're
425
+ # listed (lint reported them 'unlisted' before) instead of leaving the
426
+ # backlog inconsistent (BT-105).
427
+ core.heal_backlog!
428
+ puts "\nMigrated #{all_files.size} stories. Next ID: #{core.format_id(core.next_id_value)}"
429
+ puts 'backlog.md reconciled - review it for any pre-migration lines without an ID.'
430
+ end
431
+ end
432
+
433
+ install_dashboard_server_task
434
+ end
435
+
436
+ # NS-independent: the dashboard server serves every project, so it is
437
+ # installed even when no NS is set.
438
+ # `default_path` is the dashboard the Rakefile names; $DASHBOARD still wins,
439
+ # then ./dashboard.md. Without it the task only worked from the Rakefile's
440
+ # own directory (BT-179).
441
+ def self.install_dashboard_server_task(default_path = nil)
442
+ Rake.application.tasks.each { |t| t.clear if t.name == 'story:dashboard_server' }
443
+
444
+ namespace :story do
445
+ desc 'Start the centralized dashboard server (reads dashboard.md): rake story:dashboard_server'
446
+ task :dashboard_server do
447
+ require 'bacon_tracker/server'
448
+ require 'bacon_tracker/dashboard'
449
+
450
+ dashboard_path = ENV['DASHBOARD'] || default_path || File.join(Dir.pwd, 'dashboard.md')
451
+ abort "dashboard.md not found at #{dashboard_path}\n\nCreate it with:\n\n ## My Project\n path: /path/to/my-project # holds tracker/ (and docs/ if you have one)\n namespace: MYP\n" unless File.exist?(dashboard_path)
452
+
453
+ dashboard = BaconTracker::Dashboard.new(dashboard_path)
454
+ abort "No projects found in #{dashboard_path}" if dashboard.projects.empty?
455
+
456
+ port = BaconTracker::Tasks.env_port
457
+ puts "Bacon Dashboard running at http://localhost:#{port}"
458
+ puts "Projects: #{dashboard.projects.map(&:name).join(', ')}"
459
+ puts 'Ctrl-C to stop.'
460
+ BaconTracker::Server.boot_dashboard(dashboard).run!(port: port, bind: 'localhost', quiet: true)
461
+ end
462
+ end
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,3 @@
1
+ module BaconTracker
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,122 @@
1
+ /* Structural board CSS shared by the story board and the decisions board
2
+ (BT-137/BT-144). Colour rules stay per-view: [data-stage] in index,
3
+ [data-status] in decisions. */
4
+ .board {
5
+ display: flex;
6
+ flex: 1;
7
+ gap: 8px;
8
+ padding: 10px;
9
+ overflow: hidden;
10
+ min-height: 0;
11
+ }
12
+
13
+ /* ── Columns ─────────────────────────────────────────────────── */
14
+
15
+ .column {
16
+ flex: 1;
17
+ border-radius: var(--radius-card);
18
+ display: flex;
19
+ flex-direction: column;
20
+ min-width: 0;
21
+ border: 1px solid transparent;
22
+ transition: border-color 0.15s;
23
+ }
24
+
25
+ .column.collapsed {
26
+ flex: 0 0 auto;
27
+ width: 108px;
28
+ }
29
+ .column.collapsed .cards,
30
+ .column.collapsed .pagination,
31
+ .column.collapsed .create-form {
32
+ display: none;
33
+ }
34
+
35
+ .col-header {
36
+ padding: 10px 12px 6px;
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ flex-shrink: 0;
41
+ }
42
+
43
+ .col-label {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 2px;
47
+ }
48
+
49
+ .col-header h2 {
50
+ font-size: 0.65rem;
51
+ font-weight: 400;
52
+ letter-spacing: 0.12em;
53
+ text-transform: uppercase;
54
+ }
55
+
56
+ .col-sublabel {
57
+ font-size: 0.5rem;
58
+ letter-spacing: 0.09em;
59
+ text-transform: uppercase;
60
+ opacity: 0.5;
61
+ }
62
+
63
+ .cards {
64
+ flex: 1;
65
+ overflow-y: auto;
66
+ padding: 0 8px 8px;
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 6px;
70
+ }
71
+
72
+ .card {
73
+ background: #fff;
74
+ border-radius: var(--radius-sm);
75
+ border: 1px solid rgba(195, 195, 195, 0.4);
76
+ border-left: 3px solid transparent;
77
+ cursor: grab;
78
+ transition: box-shadow 0.15s;
79
+ user-select: none;
80
+ }
81
+ .card:hover {
82
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
83
+ }
84
+
85
+ .pagination {
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ gap: 8px;
90
+ padding: 7px 8px;
91
+ font-size: 0.68rem;
92
+ color: var(--fg-muted);
93
+ flex-shrink: 0;
94
+ }
95
+ .pagination button {
96
+ background: rgba(255, 255, 255, 0.6);
97
+ border: 1px solid var(--border);
98
+ color: var(--fg);
99
+ padding: 2px 8px;
100
+ border-radius: 4px;
101
+ cursor: pointer;
102
+ font-family: var(--font-mono);
103
+ font-size: 0.63rem;
104
+ }
105
+ .pagination button:hover {
106
+ border-color: var(--accent);
107
+ color: var(--accent);
108
+ }
109
+ /* Dark theme for the shared components - these lived page-locally in
110
+ index.erb, which is why the decisions board rendered white cards on a
111
+ dark board (BT-177). */
112
+ [data-theme="dark"] .card {
113
+ background: #2a1d26;
114
+ border-color: rgba(255, 255, 255, 0.07);
115
+ }
116
+ [data-theme="dark"] .card:hover {
117
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
118
+ }
119
+ [data-theme="dark"] .pagination button {
120
+ background: rgba(255, 255, 255, 0.07);
121
+ color: var(--fg);
122
+ }