cogworker 0.1.0 → 0.2.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.
@@ -9,42 +9,30 @@ module Cogworker
9
9
  # extension contract (an extension is free to build its own HTML, or
10
10
  # use `erb`/`Views` the same way these do).
11
11
  module Layout
12
- BUILT_IN_TABS = { 'Queues' => 'queues', 'Busy' => 'busy', 'Retries' => 'retries',
13
- 'Scheduled' => 'scheduled', 'Periodic' => 'periodic', 'Dead' => 'dead',
14
- 'History' => 'history', 'Stats' => 'stats' }.freeze
12
+ BUILT_IN_TABS = { 'Overview' => 'overview', 'Jobs' => 'jobs', 'Schedules' => 'schedules',
13
+ 'Workers' => 'workers', 'History' => 'history' }.freeze
15
14
  # Vendored under lib/cogworker/web/assets/ (served locally by
16
15
  # `Rack::Static`, wired up in `Web.build_app`) rather than fetched
17
- # from a CDN, so the Web UI works fully offline. `TAILWIND_HREF`
18
- # points at a pre-built, purged stylesheet (via the Tailwind CLI
19
- # against this app's own template files) rather than the Play CDN's
20
- # in-browser JIT script, since that script itself requires network
21
- # access to run at all.
16
+ # from a CDN, so the Web UI works fully offline. `NOCTURNE_CSS_HREF`/
17
+ # `PHOSPHOR_CSS_HREF` are the "Relay" concept's design system
18
+ # (tokens + component classes) and its icon font both self-hosted,
19
+ # same policy as htmx/AG Grid/Chart.js.
20
+ #
21
+ # No Tailwind stylesheet here any more — every built-in tab (Overview/
22
+ # Jobs/Schedules/Workers, then History/Stats last, since both leaned
23
+ # on hand-written Tailwind utility classes for their AG Grid/Chart.js
24
+ # layout) has been migrated onto these nocturne classes/tokens, and
25
+ # the vendored `assets/tailwind.css` file itself was removed with it —
26
+ # nothing in this gem references it any more.
22
27
  HTMX_SRC = 'assets/htmx.min.js'
23
- TAILWIND_HREF = 'assets/tailwind.css'
24
-
25
- BUTTON_VARIANTS = {
26
- default: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
27
- primary: 'bg-indigo-600 text-white hover:bg-indigo-500',
28
- warning: 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-900/70',
29
- danger: 'bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/60'
30
- }.freeze
31
-
32
- BADGE_VARIANTS = {
33
- success: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
34
- warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
35
- danger: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
36
- default: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
37
- }.freeze
38
-
39
- # Shared with `Routes::Stats::CARD_ACCENTS` (its big card grid on the
40
- # actual Stats tab) — a single source of truth for which color each of
41
- # the 6 job counters gets, whether rendered as a big card there or as
42
- # a compact chip in `stats_bar` everywhere else.
43
- JOB_STAT_ACCENTS = {
44
- 'Enqueued' => 'text-indigo-600 dark:text-indigo-400', 'Processed' => 'text-green-600 dark:text-green-400',
45
- 'Failed' => 'text-red-600 dark:text-red-400', 'Retries' => 'text-amber-600 dark:text-amber-400',
46
- 'Scheduled' => 'text-sky-600 dark:text-sky-400', 'Dead' => 'text-gray-500 dark:text-gray-400'
47
- }.freeze
28
+ NOCTURNE_CSS_HREF = 'assets/nocturne/styles.css'
29
+ PHOSPHOR_CSS_HREF = 'assets/phosphor/style.css'
30
+
31
+ BUTTON_VARIANTS = { default: 'btn-secondary', primary: 'btn-primary', warning: 'btn-warning',
32
+ danger: 'btn-danger', success: 'btn-success' }.freeze
33
+
34
+ BADGE_VARIANTS = { success: 'tag-success', warning: 'tag-warning', danger: 'tag-danger',
35
+ default: 'tag-neutral' }.freeze
48
36
 
49
37
  module_function
50
38
 
@@ -52,7 +40,7 @@ module Cogworker
52
40
  # every `interval` and swaps the response into this same element's
53
41
  # innerHTML. The route behind `path` only needs to answer both "full
54
42
  # page" (a normal GET) and "just this fragment" (`Action#hx_request?`
55
- # true) — see e.g. Routes::Busy.
43
+ # true) — see e.g. Routes::Workers.
56
44
  # The `[window.cogworkerLiveUpdate]` event filter (htmx polling
57
45
  # syntax) makes every self-polling tab respect the global live-update
58
46
  # toggle (see `live_update_script`/`live_toggle_button` below) without
@@ -74,29 +62,50 @@ module Cogworker
74
62
  "#{script_name}/#{relative}"
75
63
  end
76
64
 
77
- # `show_stats_bar: false` skips the global counter strip entirely
78
- # used only by the Stats tab itself (`Routes::Stats`), which already
79
- # shows the same 6 numbers as its own, bigger card grid right below;
80
- # repeating them again in the compact bar right above would just be
81
- # the exact same figures twice on one page.
82
- def wrap(title, body, script_name: '', extra_head: '', show_stats_bar: true)
65
+ # `body`'s own `min-width: max-content` (not a hand-picked pixel
66
+ # number) lets the browser compute the page's own minimum width from
67
+ # whatever's actually inside it right now that's the header (see
68
+ # its own comment below), but it's self-adjusting: it'd grow or
69
+ # shrink again on its own if the header's content ever changes, no
70
+ # number to update by hand. Real payoff for `main` specifically: as
71
+ # the other direct child of this `display: flex; flex-direction:
72
+ # column` body, `main`'s default `align-items: stretch` cross-size
73
+ # already tracks *whatever width body ends up being* — so once
74
+ # body's own min-width is driven by the header, `main` stretches to
75
+ # match it too, rather than independently shrinking to the viewport
76
+ # while the header overflows past it (a real bug once: the page grew
77
+ # a horizontal scrollbar for the header, but `main`'s own content
78
+ # kept getting squeezed narrower against the viewport instead of
79
+ # riding along at the same width). The other direction of the same
80
+ # bug also hit once `main` was riding along correctly: `min-width:
81
+ # max-content` on `body` takes the *max* of every child's own
82
+ # max-content contribution, so `#cw-main`'s own (a wide table/card
83
+ # grid, capped at 1480px by its own `max-width`) was *also* feeding
84
+ # into it — and being bigger than the header's true ~1090px minimum,
85
+ # it won, flooring the whole page at 1480px instead: the header sat
86
+ # frozen, not tracking the window at all, for the entire 1090–1480px
87
+ # range, only reacting once you crossed below 1480. `.cw-main`'s own
88
+ # `contain: inline-size` (styles.css) is what excludes it from that
89
+ # calculation, leaving the header as the only real contributor.
90
+ def wrap(title, body, script_name: '', extra_head: '')
83
91
  <<~HTML
84
92
  <!doctype html>
85
- <html class="h-full">
93
+ <html>
86
94
  <head>
87
95
  <meta charset="utf-8">
88
96
  <meta name="viewport" content="width=device-width, initial-scale=1">
89
97
  <title>#{h(title)} · Cogworker</title>
90
- <link rel="stylesheet" href="#{path(script_name, TAILWIND_HREF)}">
98
+ <link rel="stylesheet" href="#{path(script_name, NOCTURNE_CSS_HREF)}">
99
+ <link rel="stylesheet" href="#{path(script_name, PHOSPHOR_CSS_HREF)}">
91
100
  <script src="#{path(script_name, HTMX_SRC)}"></script>
92
101
  #{time_script}
93
102
  #{live_update_script}
103
+ #{wide_layout_script}
94
104
  #{extra_head}
95
105
  </head>
96
- <body class="h-full bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 antialiased">
106
+ <body style="min-height: 100vh; display: flex; flex-direction: column; min-width: max-content;">
97
107
  #{header(title, script_name)}
98
- #{stats_bar_strip(script_name) if show_stats_bar}
99
- <main class="w-full px-4 sm:px-6 lg:px-8 py-8">
108
+ <main id="cw-main" class="cw-main" style="flex: 1; padding: 22px; display: flex; flex-direction: column; gap: 20px;">
100
109
  #{body}
101
110
  </main>
102
111
  </body>
@@ -104,53 +113,125 @@ module Cogworker
104
113
  HTML
105
114
  end
106
115
 
107
- def stats_bar_strip(script_name)
116
+ # Behaves like a desktop app's toolbar: never wraps onto a second
117
+ # line, no matter how narrow the window — below its natural content
118
+ # width, the *page* scrolls horizontally instead (`wrap` above's
119
+ # `min-width: max-content` on `body` is what turns this row's own
120
+ # unshrinkable width into the *page's* scrollable minimum, `main`
121
+ # included). What needs to be explicit here is `flex-wrap: nowrap` at
122
+ # every level that could otherwise wrap (this row, `.nav`, the
123
+ # right-hand action group), and — the easy-to-miss half of it —
124
+ # `white-space: nowrap` on every label that could otherwise
125
+ # line-break internally (`.btn` in styles.css covers the buttons;
126
+ # nav links already get it from `nav_link` below). Without the
127
+ # latter, flexbox's own "never shrink a row below its content's
128
+ # minimum size" protection still holds, but that minimum is computed
129
+ # from the *longest word*, not the whole label, once wrapping is
130
+ # allowed — so a button like "Pause intake" still gets squeezed down
131
+ # and its label splits into two lines ("Pause"/"intake") instead of
132
+ # the row ever overflowing. This was caught by hand — a fixed pixel
133
+ # `min-width` was tried first as a shortcut for both this and body's
134
+ # own min-width above, then dropped for the CSS-computed `max-
135
+ # content`/`white-space: nowrap` combination once the real causes
136
+ # turned out to make a hardcoded number unnecessary either way.
137
+ def header(title, script_name)
138
+ links = BUILT_IN_TABS.merge(Web.tabs).map do |label, p|
139
+ nav_link(label, p, script_name, active: label == title)
140
+ end.join
108
141
  <<~HTML
109
- <div class="border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950">
110
- <div class="w-full px-4 sm:px-6 lg:px-8 py-2">
111
- #{stats_bar(script_name)}
142
+ <header style="display: flex; align-items: center; gap: 14px; padding: 12px 22px; background: linear-gradient(180deg, color-mix(in srgb, var(--color-text) 4%, var(--color-bg)), var(--color-bg)); box-shadow: inset 0 -1px 0 var(--color-divider); position: sticky; top: 0; z-index: 5;">
143
+ <span style="font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 18px; letter-spacing: -0.02em; white-space: nowrap;">Cogworker</span>
144
+ <nav class="nav" style="padding: 0; gap: 2px; flex-wrap: nowrap;">#{links}</nav>
145
+ <div style="margin-left: auto; display: flex; align-items: center; gap: 14px; flex-wrap: nowrap;">
146
+ #{cluster_bar(script_name)}
147
+ #{pause_intake_button(script_name)}
148
+ #{resume_intake_button(script_name)}
149
+ #{wide_layout_toggle_button}
150
+ #{live_toggle_button}
112
151
  </div>
113
- </div>
152
+ </header>
114
153
  HTML
115
154
  end
116
155
 
117
- # The 6 job counters (Enqueued/Processed/Failed/Retries/Scheduled/
118
- # Dead) as a compact, always-visible strip under the header same
119
- # counters as the big card grid on the Stats tab itself
120
- # (`Routes::Stats`), just rendered small enough to sit on every page
121
- # without pushing content down much. Self-polling (`poll_div`) via a
122
- # tiny dedicated `GET /stats/bar` fragment route (see `Routes::Stats`)
123
- # — always just this fragment, never a full page — so it stays live
124
- # and respects the same global toggle as everything else, without
125
- # every route needing to compute it itself.
126
- def stats_bar(script_name)
127
- poll_div('global-stats-bar', script_name, 'stats/bar', stats_bar_content)
156
+ # `production · N workers` in the "Relay" concept mock, minus the
157
+ # fabricated environment name (Cogworker has no such concept)just
158
+ # the real worker count (`ProcessSet`) and a status dot colored by
159
+ # whether any of them are actually taking work right now, refreshed
160
+ # via a tiny dedicated fragment route (`Routes::Workers`'s own `GET
161
+ # /workers/summary`).
162
+ def cluster_bar(script_name)
163
+ poll_div('cluster-bar', script_name, 'workers/summary', cluster_bar_content)
128
164
  end
129
165
 
130
- def stats_bar_content
131
- stats = Cogworker::Stats.new
132
- values = {
133
- 'Enqueued' => stats.enqueued, 'Processed' => stats.processed, 'Failed' => stats.failed,
134
- 'Retries' => stats.retry_size, 'Scheduled' => stats.scheduled_size, 'Dead' => stats.dead_size
135
- }
136
- items = values.map { |label, value| stat_chip(label, value) }.join
137
- %(<div class="flex flex-wrap gap-2">#{items}</div>)
138
- end
139
-
140
- # Each counter its own bordered pill (not just plain text separated by
141
- # a gap) — border-only in dark mode (no fill) so it reads as an
142
- # outline against the page background rather than a lighter box
143
- # sitting on top of it; a solid `bg-white` still works fine in light
144
- # mode, where the surrounding strip is itself already light.
145
- def stat_chip(label, value)
166
+ def cluster_bar_content
167
+ processes = Cogworker::ProcessSet.new.to_a
168
+ active = processes.any? { |p| ![true, 'true'].include?(p['quiet']) }
169
+ dot_glow = active ? ' box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);' : ''
170
+ dot_color = active ? 'var(--color-success)' : 'var(--color-neutral-600)'
171
+ count = processes.size
146
172
  <<~HTML.strip
147
- <span class="inline-flex items-center gap-1 rounded-md border border-gray-200 dark:border-gray-800 dark:bg-transparent px-2 py-1 text-xs whitespace-nowrap">
148
- <span class="text-gray-500 dark:text-gray-400">#{h(label)}</span>
149
- <span class="font-semibold #{JOB_STAT_ACCENTS.fetch(label, '')}">#{h(value)}</span>
173
+ <span style="display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--color-neutral-400); white-space: nowrap;">
174
+ <span style="width: 7px; height: 7px; border-radius: 50%; background: #{dot_color};#{dot_glow}"></span>
175
+ #{count} #{count == 1 ? 'worker' : 'workers'}
150
176
  </span>
151
177
  HTML
152
178
  end
153
179
 
180
+ # Cluster-wide "quiet every process" (`Routes::Workers`'s own `POST
181
+ # /workers/pause_all`) — the mock's "Pause intake" button. Unlike real
182
+ # Sidekiq OSS (where quiet is a one-way trip back to a fresh process),
183
+ # `Manager#quiet` here is a plain in-memory flag, so `resume_intake_
184
+ # button` below is a genuine, symmetric undo rather than a full
185
+ # process restart.
186
+ def pause_intake_button(script_name)
187
+ action = path(script_name, 'workers/pause_all')
188
+ action_button(action, 'Pause intake', hx_target: '#cluster-bar', icon: 'pause')
189
+ end
190
+
191
+ # The undo for the button above (`POST /workers/resume_all`) — both
192
+ # buttons are always shown side by side rather than toggling one for
193
+ # the other, since `#cluster-bar` only reports an aggregate "any
194
+ # process active?" dot, not enough to know which single action applies
195
+ # cluster-wide when processes are in a mixed state.
196
+ def resume_intake_button(script_name)
197
+ action = path(script_name, 'workers/resume_all')
198
+ action_button(action, 'Resume intake', hx_target: '#cluster-bar', variant: :success, icon: 'play')
199
+ end
200
+
201
+ # `.nav a`/`.nav a[aria-current='page']` (styles.css) already turn a
202
+ # link accent-colored on hover or when active — only the active
203
+ # pill's background tint needs to be added here per-link, so this
204
+ # stays in sync with the design system's own hover/focus treatment
205
+ # instead of duplicating it.
206
+ def nav_link(label, relative_path, script_name, active:)
207
+ bg = active ? ' background: color-mix(in srgb, var(--color-accent) 14%, transparent);' : ''
208
+ attrs = active ? ' aria-current="page"' : ''
209
+ %(<a href="#{path(script_name,
210
+ relative_path)}"#{attrs} style="display: inline-block; padding: 6px 11px; border-radius: var(--radius-md); white-space: nowrap;#{bg}">#{h(label)}</a>)
211
+ end
212
+
213
+ # One global on/off switch for every auto-refreshing tab (the
214
+ # htmx-polled Workers/Overview tabs via `poll_div`'s event filter, and
215
+ # History's own AG Grid data refresh) — persisted in `localStorage` so
216
+ # it survives navigating between tabs/reloading the page.
217
+ def live_toggle_button
218
+ %(<button type="button" data-cw-live-toggle class="btn btn-secondary" onclick="window.cogworkerSetLiveUpdate(!window.cogworkerLiveUpdate)"
219
+ aria-pressed="true" title="Toggle live auto-refresh"
220
+ style="flex-shrink: 0; font-size: 13px;">⏸ Live</button>)
221
+ end
222
+
223
+ # Global fixed-width/full-width switch for `<main>` (`#cw-main`, the
224
+ # `.cw-main`/`.cw-main--wide` pair in styles.css) — same persisted-in-
225
+ # `localStorage`, applies-on-every-page shape as `live_toggle_button`/
226
+ # `live_update_script` above, just flipping a layout class instead of
227
+ # gating polling. Defaults to fixed width (matching the design as
228
+ # exported) when nothing's stored yet.
229
+ def wide_layout_toggle_button
230
+ %(<button type="button" data-cw-wide-toggle class="btn btn-secondary" onclick="window.cogworkerSetWideLayout(!window.cogworkerWideLayout)"
231
+ aria-pressed="false" title="Toggle fixed-width/full-width layout"
232
+ style="flex-shrink: 0; font-size: 13px;">⛶ Full width</button>)
233
+ end
234
+
154
235
  # Every `time_tag` renders a UTC fallback (readable with JS disabled,
155
236
  # or before this runs) plus a `datetime=` attribute; this rewrites
156
237
  # each one's visible text to the *browser's local* time, in
@@ -225,36 +306,10 @@ module Cogworker
225
306
  %(<time datetime="#{time.utc.iso8601}" data-cw-time>#{h(time.utc.strftime(Web.time_format))}</time>)
226
307
  end
227
308
 
228
- def header(title, script_name)
229
- links = BUILT_IN_TABS.merge(Web.tabs).map do |label, p|
230
- nav_link(label, p, script_name, active: label == title)
231
- end.join
232
- <<~HTML
233
- <header class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
234
- <div class="w-full px-4 sm:px-6 lg:px-8">
235
- <div class="flex items-center h-14 gap-1">
236
- <span class="font-semibold tracking-tight mr-4">⚙️ Cogworker</span>
237
- <nav class="flex gap-1 overflow-x-auto">#{links}</nav>
238
- #{live_toggle_button}
239
- </div>
240
- </div>
241
- </header>
242
- HTML
243
- end
244
-
245
309
  # One global on/off switch for every auto-refreshing tab (the
246
- # htmx-polled Busy/Stats/Queues tabs via `poll_div`'s event filter, and
310
+ # htmx-polled Workers/Overview tabs via `poll_div`'s event filter, and
247
311
  # History's own AG Grid data refresh) — persisted in `localStorage` so
248
312
  # it survives navigating between tabs/reloading the page.
249
- def live_toggle_button
250
- %(<button type="button" data-cw-live-toggle onclick="window.cogworkerSetLiveUpdate(!window.cogworkerLiveUpdate)"
251
- aria-pressed="true" title="Toggle live auto-refresh"
252
- class="ml-auto flex-shrink-0 px-3 py-1.5 rounded-md text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200">⏸ Live</button>)
253
- end
254
-
255
- # Runs before the rest of the page so `window.cogworkerLiveUpdate` is
256
- # already set by the time htmx evaluates its first `every ... [...]`
257
- # poll filter or a route's own refresh script checks it.
258
313
  def live_update_script
259
314
  <<~HTML
260
315
  <script>
@@ -282,56 +337,88 @@ module Cogworker
282
337
  HTML
283
338
  end
284
339
 
285
- def nav_link(label, relative_path, script_name, active:)
286
- classes = if active
287
- 'bg-indigo-600 text-white'
288
- else
289
- 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
290
- end
291
- %(<a href="#{path(script_name,
292
- relative_path)}" class="px-3 py-1.5 rounded-md text-sm font-medium whitespace-nowrap #{classes}">#{h(label)}</a>)
340
+ # Companion to `wide_layout_toggle_button` above — same persisted-
341
+ # in-`localStorage`, apply-on-`DOMContentLoaded`-and-every-toggle
342
+ # shape as `live_update_script`, just toggling `#cw-main`'s
343
+ # `cw-main--wide` class (styles.css) instead of a polling flag.
344
+ # Applied directly here (not deferred to a `poll_div`/htmx swap)
345
+ # since `#cw-main` itself is never one of those fragments — it's the
346
+ # element every fragment lives *inside*.
347
+ def wide_layout_script
348
+ <<~HTML
349
+ <script>
350
+ (function () {
351
+ function readStored() {
352
+ try { return localStorage.getItem('cogworkerWideLayout') === 'true'; } catch (e) { return false; }
353
+ }
354
+ window.cogworkerWideLayout = readStored();
355
+ window.cogworkerSetWideLayout = function (on) {
356
+ window.cogworkerWideLayout = on;
357
+ try { localStorage.setItem('cogworkerWideLayout', on ? 'true' : 'false'); } catch (e) {}
358
+ var main = document.getElementById('cw-main');
359
+ if (main) main.classList.toggle('cw-main--wide', on);
360
+ document.querySelectorAll('[data-cw-wide-toggle]').forEach(function (btn) {
361
+ btn.textContent = on ? '⛶ Fixed width' : '⛶ Full width';
362
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
363
+ });
364
+ };
365
+ document.addEventListener('DOMContentLoaded', function () {
366
+ window.cogworkerSetWideLayout(window.cogworkerWideLayout);
367
+ });
368
+ })();
369
+ </script>
370
+ HTML
293
371
  end
294
372
 
295
373
  def h(str)
296
374
  CGI.escapeHTML(str.to_s)
297
375
  end
298
376
 
299
- def table(headers, rows, empty_message: 'Nothing here.')
300
- return %(<p class="text-sm text-gray-500 dark:text-gray-400 italic">#{h(empty_message)}</p>) if rows.empty?
377
+ # `wrapped: false` skips this table's own bordered/shadowed card —
378
+ # for a caller that's already putting it inside one of its own (e.g.
379
+ # a `<section>` alongside an `<h4>`, matching Overview's Throughput/
380
+ # Redis cards), where the default wrapper would just nest one bordered
381
+ # box inside another.
382
+ def table(headers, rows, empty_message: 'Nothing here.', wrapped: true)
383
+ return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>) if rows.empty?
301
384
 
302
- head = headers.map do |c|
303
- %(<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">#{h(c)}</th>)
304
- end.join
385
+ head = headers.map { |c| %(<th>#{h(c)}</th>) }.join
305
386
  body = rows.map do |row|
306
- cells = row.map { |cell| %(<td class="px-4 py-2 text-sm align-middle">#{cell}</td>) }.join
307
- %(<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">#{cells}</tr>)
387
+ cells = row.map { |cell| %(<td>#{cell}</td>) }.join
388
+ %(<tr>#{cells}</tr>)
308
389
  end.join
309
- <<~HTML
310
- <div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm">
311
- <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
312
- <thead class="bg-gray-50 dark:bg-gray-800/60"><tr>#{head}</tr></thead>
313
- <tbody class="divide-y divide-gray-100 dark:divide-gray-800">#{body}</tbody>
314
- </table>
315
- </div>
390
+ inner = <<~HTML
391
+ <table class="table">
392
+ <thead><tr>#{head}</tr></thead>
393
+ <tbody>#{body}</tbody>
394
+ </table>
316
395
  HTML
396
+ return inner unless wrapped
397
+
398
+ %(<div style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 8px 6px 4px; overflow-x: auto;">#{inner}</div>)
317
399
  end
318
400
 
319
401
  def section(heading, body)
320
- %(<h2 class="text-lg font-semibold mt-8 mb-3">#{h(heading)}</h2>#{body})
402
+ %(<h3 style="font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px; margin: var(--space-8) 0 var(--space-3);">#{h(heading)}</h3>#{body})
321
403
  end
322
404
 
323
- # A one-button `<form>`, wired for both htmx (`hx-post`/`hx-target`)
324
- # and a plain, JS-less POST fallback to the same `action` URL.
325
405
  def badge(label, variant: :default)
326
- %(<span class="px-2 py-0.5 rounded-full text-xs font-medium #{BADGE_VARIANTS.fetch(variant)}">#{h(label)}</span>)
406
+ %(<span class="tag #{BADGE_VARIANTS.fetch(variant)}">#{h(label)}</span>)
327
407
  end
328
408
 
329
- def form_button(action, hidden_name, hidden_value, label, hx_target:, variant: :default)
330
- classes = "px-2.5 py-1 rounded-md text-xs font-medium #{BUTTON_VARIANTS.fetch(variant)}"
409
+ # A one-button `<form>`, wired for both htmx (`hx-post`/`hx-target`)
410
+ # and a plain, JS-less POST fallback to the same `action` URL.
411
+ # `icon:` (a Phosphor icon name, e.g. `'arrow-clockwise'`) is optional
412
+ # — the "Relay" concept mock only puts icons on a page's few
413
+ # prominent/standalone actions (a detail panel's own buttons, the
414
+ # header's "Pause intake"), never on the small, repeated actions
415
+ # packed into a dense table row, so most call sites omit it.
416
+ def form_button(action, hidden_name, hidden_value, label, hx_target:, variant: :default, icon: nil)
417
+ classes = "btn #{BUTTON_VARIANTS.fetch(variant)}"
331
418
  <<~HTML
332
- <form class="inline" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
419
+ <form style="display: inline;" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
333
420
  <input type="hidden" name="#{hidden_name}" value="#{h(hidden_value)}">
334
- <button type="submit" class="#{classes}">#{h(label)}</button>
421
+ <button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{icon_tag(icon)}#{h(label)}</button>
335
422
  </form>
336
423
  HTML
337
424
  end
@@ -339,14 +426,18 @@ module Cogworker
339
426
  # Same wiring as `form_button`, minus the hidden identifying field —
340
427
  # for a whole-collection action (e.g. "delete all") that doesn't
341
428
  # target one particular row.
342
- def action_button(action, label, hx_target:, variant: :default)
343
- classes = "px-2.5 py-1 rounded-md text-xs font-medium #{BUTTON_VARIANTS.fetch(variant)}"
429
+ def action_button(action, label, hx_target:, variant: :default, icon: nil)
430
+ classes = "btn #{BUTTON_VARIANTS.fetch(variant)}"
344
431
  <<~HTML
345
- <form class="inline" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
346
- <button type="submit" class="#{classes}">#{h(label)}</button>
432
+ <form style="display: inline;" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
433
+ <button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{icon_tag(icon)}#{h(label)}</button>
347
434
  </form>
348
435
  HTML
349
436
  end
437
+
438
+ def icon_tag(name)
439
+ name ? %(<i class="ph ph-#{name}"></i>) : ''
440
+ end
350
441
  end
351
442
  end
352
443
  end