From a88eba7dd14e84f9f2effe053145518bb10802a7 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Tue, 31 Mar 2026 10:04:33 +0200 Subject: [PATCH] tighten whitespace, DRY shell and buttons --- AGENTS.md | 2 +- repub/components.py | 180 ++++++++++++++++++++++++++------------- repub/pages/dashboard.py | 29 +++---- repub/pages/runs.py | 67 +++++---------- repub/pages/settings.py | 18 ++-- repub/pages/shim.py | 67 +++++++-------- repub/pages/sources.py | 38 ++------- repub/static/app.css | 93 ++++++++++++++------ tests/test_web.py | 170 +++++++++++++++++++++++++++++++++++- 9 files changed, 439 insertions(+), 225 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e9c932..9c288fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ The only way for actions to affect the view returned by the render-fn running in - Enter the dev environment with `nix develop` if you are not already inside it - Sync Python dependencies with `uv sync --all-groups`. - Run the app with `uv run repub`. -- Generate CSS with `tailwindcss -i ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds. +- Generate CSS with `tailwindcss -i ./repub/static/app.tailwind.css -o ./repub/static/app.css` and add `--watch` when you need live rebuilds. ```sh uv sync --all-groups diff --git a/repub/components.py b/repub/components.py index 035b74c..8cb0dda 100644 --- a/repub/components.py +++ b/repub/components.py @@ -4,6 +4,41 @@ import htpy as h from htpy import Node, Renderable +def _button_classes(*, tone: str, emphasis: str, disabled: bool = False) -> str: + base = ( + "inline-flex items-center justify-center rounded-full font-semibold transition " + ) + emphasis_classes = { + "compact": "px-3 py-1.5 text-sm", + "regular": "px-4 py-2.5 text-sm", + "soft": "px-3.5 py-2 text-sm", + } + tone_classes = { + "amber": "bg-amber-400 text-slate-950 hover:bg-amber-300", + "header-secondary": ( + "border border-white/15 bg-white/5 text-white hover:bg-white/10" + ), + "muted": "border border-slate-200 bg-white text-slate-700 shadow-sm hover:bg-slate-50", + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + "success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200", + "dark": "bg-slate-950 text-white hover:bg-slate-800", + } + disabled_classes = { + "default": "bg-slate-100 text-slate-400", + "danger": "bg-slate-100 text-slate-400", + "success": "bg-slate-100 text-slate-400", + "dark": "bg-slate-300 text-white/80", + } + interactive = "cursor-not-allowed" if disabled else "cursor-pointer" + colors = ( + disabled_classes.get(tone, "bg-slate-100 text-slate-400") + if disabled + else tone_classes[tone] + ) + return f"{base}{emphasis_classes[emphasis]} {interactive} {colors}" + + def base_layout(*, page_title: str, stylesheet_href: str, content: Node) -> Renderable: return h.html(lang="en", class_="h-full bg-slate-100")[ h.head[ @@ -43,15 +78,15 @@ def admin_sidebar( *, current_path: str, source_count: int = 0, running_count: int = 0 ) -> Renderable: return h.aside( - class_="relative overflow-hidden bg-slate-950 px-6 py-8 text-white lg:min-h-screen" + class_="relative overflow-hidden bg-slate-950 px-4 py-6 text-white lg:min-h-screen" )[ h.div( class_="absolute inset-x-0 top-0 h-40 bg-radial from-amber-400/25 via-amber-400/10 to-transparent" ), h.div(class_="relative flex h-full flex-col")[ - h.div(class_="flex items-center gap-3")[ + h.div(class_="flex items-center gap-2.5")[ h.div( - class_="flex size-11 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" + class_="flex size-10 items-center justify-center rounded-2xl bg-amber-400 text-base font-black text-slate-950" )["AR"], h.div[ h.p( @@ -59,7 +94,7 @@ def admin_sidebar( )["Republisher"], ], ], - h.nav(class_="mt-10 space-y-2")[ + h.nav(class_="mt-8 space-y-2")[ nav_link( label="Dashboard", href="/", @@ -86,7 +121,7 @@ def admin_sidebar( badge="App", ), ], - h.div(class_="mt-auto rounded-3xl bg-white/5 p-5 ring-1 ring-white/10")[ + h.div(class_="mt-auto rounded-3xl bg-white/5 p-4 ring-1 ring-white/10")[ h.p(class_="text-sm font-semibold text-white")[ "AnyNews Republisher v2.0" ], @@ -101,21 +136,21 @@ def admin_sidebar( def header_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_="inline-flex items-center rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm transition hover:bg-amber-300", + class_=_button_classes(tone="amber", emphasis="regular"), )[label] def header_secondary_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_="inline-flex items-center rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/10", + class_=_button_classes(tone="header-secondary", emphasis="regular"), )[label] def muted_action_link(*, href: str, label: str) -> Renderable: return h.a( href=href, - class_="inline-flex items-center rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50", + class_=_button_classes(tone="muted", emphasis="soft"), )[label] @@ -131,22 +166,58 @@ def inline_link(*, href: str, label: str, tone: str = "default") -> Renderable: )[label] +def action_button( + *, + label: str, + tone: str = "default", + emphasis: str = "compact", + disabled: bool = False, + button_type: str = "button", + post_path: str | None = None, +) -> Renderable: + attributes: dict[str, str] = {} + if post_path is not None and not disabled: + attributes["data-on:pointerdown"] = f"@post('{post_path}')" + return h.button( + attributes, + type=button_type, + disabled=disabled, + class_=_button_classes(tone=tone, emphasis=emphasis, disabled=disabled), + )[label] + + def inline_button( *, label: str, tone: str = "default", disabled: bool = False ) -> Renderable: - classes = { - "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", - "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", - "success": "bg-emerald-100 text-emerald-800 hover:bg-emerald-200", - } - class_name = ( - "cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone] - ) - return h.button( - type="button", + return action_button( + label=label, + tone=tone, + emphasis="compact", + button_type="button", disabled=disabled, - class_=f"inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition {class_name}", - )[label] + ) + + +def app_shell( + *, + current_path: str, + source_count: int = 0, + running_count: int = 0, + content: Node, +) -> Renderable: + return h.main( + id="morph", + class_="min-h-screen lg:grid lg:grid-cols-[14rem_minmax(0,1fr)]", + )[ + admin_sidebar( + current_path=current_path, + source_count=source_count, + running_count=running_count, + ), + h.div(class_="px-4 py-4 sm:px-4 lg:px-5 lg:py-4")[ + h.div(class_="mx-auto max-w-7xl space-y-4")[content] + ], + ] def page_shell( @@ -160,39 +231,30 @@ def page_shell( running_count: int = 0, content: Node, ) -> Renderable: - return h.main( - id="morph", - class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", - )[ - admin_sidebar( - current_path=current_path, - source_count=source_count, - running_count=running_count, + return app_shell( + current_path=current_path, + source_count=source_count, + running_count=running_count, + content=( + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" + )[ + h.div(class_="max-w-3xl")[ + h.h1( + class_="text-3xl font-semibold tracking-tight text-slate-950" + )[title], + ( + description + and h.p(class_="mt-1 text-sm text-slate-600")[description] + ), + ], + actions and h.div(class_="flex flex-wrap gap-2")[actions], + ] + ], + content, ), - h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ - h.div(class_="mx-auto max-w-7xl space-y-5")[ - h.section[ - h.div( - class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" - )[ - h.div(class_="max-w-3xl")[ - h.h1( - class_="text-3xl font-semibold tracking-tight text-slate-950" - )[title], - ( - description - and h.p(class_="mt-1 text-sm text-slate-600")[ - description - ] - ), - ], - actions and h.div(class_="flex flex-wrap gap-2")[actions], - ] - ], - content, - ] - ], - ] + ) def section_card(*, content: Node) -> Renderable: @@ -212,12 +274,12 @@ def table_section( def render_row(row: tuple[Node, ...]) -> Renderable: first_cell, *other_cells = row return h.tr(class_="align-top")[ - h.td(class_="py-4 pr-6 pl-4 text-sm font-medium text-slate-950 sm:pl-6")[ + h.td(class_="py-3 pr-5 pl-3 text-sm font-medium text-slate-950 sm:pl-4")[ first_cell ], ( h.td( - class_="px-3 py-4 align-top text-sm whitespace-nowrap text-slate-600" + class_="px-2.5 py-3 align-top text-sm whitespace-nowrap text-slate-600" )[cell] for cell in other_cells ), @@ -230,12 +292,14 @@ def table_section( body_rows = h.tr[ h.td( colspan=str(len(headers)), - class_="px-4 py-8 text-center text-sm text-slate-500 sm:px-6", + class_="px-3 py-8 text-center text-sm text-slate-500 sm:px-4", )[empty_message] ] return h.section[ - h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between")[ + h.div( + class_="flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-between" + )[ h.div[ eyebrow and h.p( @@ -251,14 +315,14 @@ def table_section( )[ h.div(class_="overflow-x-auto")[ h.table( - class_="relative w-full min-w-[72rem] divide-y divide-slate-200 table-auto" + class_="relative w-full min-w-[64rem] divide-y divide-slate-200 table-auto" )[ h.thead(class_="bg-stone-50")[ h.tr[ ( h.th( scope="col", - class_="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-4 sm:first:pl-6", + class_="px-2.5 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.18em] whitespace-nowrap text-slate-500 first:pl-3 sm:first:pl-4", )[header] for header in headers ) diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index ef75847..ad68076 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -6,7 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( - admin_sidebar, + app_shell, header_action_link, inline_button, inline_link, @@ -253,21 +253,14 @@ def dashboard_page_with_data( ) -> Renderable: running_items = running_executions or () source_items = source_feeds or () - return h.main( - id="morph", - class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", - )[ - admin_sidebar( - current_path="/", - source_count=len(source_items), - running_count=len(running_items), + return app_shell( + current_path="/", + source_count=len(source_items), + running_count=len(running_items), + content=( + dashboard_header(), + operational_snapshot(snapshot=snapshot), + running_executions_table(running_executions=running_items), + published_feeds_table(source_feeds=source_items), ), - h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ - h.div(class_="mx-auto max-w-7xl space-y-5")[ - dashboard_header(), - operational_snapshot(snapshot=snapshot), - running_executions_table(running_executions=running_items), - published_feeds_table(source_feeds=source_items), - ] - ], - ] + ) diff --git a/repub/pages/runs.py b/repub/pages/runs.py index ca9cce9..24418dd 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -6,6 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( + action_button, inline_link, muted_action_link, page_shell, @@ -15,34 +16,6 @@ from repub.components import ( ) -def _action_button( - *, - label: str, - tone: str = "default", - disabled: bool = False, - post_path: str | None = None, -) -> Renderable: - classes = { - "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", - "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", - } - class_name = ( - "cursor-not-allowed bg-slate-100 text-slate-400" if disabled else classes[tone] - ) - attributes: dict[str, str] = {} - if post_path is not None and not disabled: - attributes["data-on:pointerdown"] = f"@post('{post_path}')" - return h.button( - attributes, - type="button", - disabled=disabled, - class_=( - "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " - f"text-sm font-semibold transition {class_name}" - ), - )[label] - - def _text(values: Mapping[str, object], key: str) -> str: return str(values[key]) @@ -62,7 +35,7 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -73,20 +46,20 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], - h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "runtime")], + h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "runtime")], ], status_badge(label=_text(execution, "status"), tone="running"), - h.div(class_="min-w-56 whitespace-normal")[ + h.div(class_="max-w-xs whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")], - h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "worker")], + h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "worker")], ], - h.div(class_="flex flex-nowrap items-center gap-3")[ + h.div(class_="flex flex-wrap items-center gap-2")[ inline_link( href=_text(execution, "log_href"), label="View log", tone="amber", ), - _action_button( + action_button( label=_text(execution, "cancel_label"), tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), @@ -113,7 +86,7 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -128,13 +101,13 @@ def _queued_row(execution: Mapping[str, object]) -> tuple[Node, ...]: f"#{_text(execution, 'queue_position')}" ], ], - _action_button( + action_button( label=_text(execution, "run_label"), disabled=_flag(execution, "run_disabled"), post_path=_maybe_text(execution, "run_post_path"), ), - h.div(class_="flex flex-nowrap items-center gap-2")[ - _action_button( + h.div(class_="flex flex-wrap items-center gap-2")[ + action_button( label="Cancel", tone="danger", post_path=_maybe_text(execution, "cancel_post_path"), @@ -161,7 +134,7 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(job, "source")], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")], + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[_text(job, "slug")], ], h.div[next_run_label,], h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], @@ -169,20 +142,20 @@ def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: label=_text(job, "enabled_label"), tone=_text(job, "enabled_tone"), ), - h.p(class_="max-w-40 whitespace-normal text-sm text-slate-500")[ + h.p(class_="max-w-32 whitespace-normal text-sm text-slate-500")[ _text(job, "run_reason") ], - h.div(class_="flex flex-nowrap items-center gap-2")[ - _action_button( + h.div(class_="flex flex-wrap items-center gap-2")[ + action_button( label="Run now", disabled=_flag(job, "run_disabled"), post_path=_maybe_text(job, "run_post_path"), ), - _action_button( + action_button( label=_text(job, "toggle_label"), post_path=_maybe_text(job, "toggle_post_path"), ), - _action_button( + action_button( label="Delete", tone="danger", post_path=_maybe_text(job, "delete_post_path"), @@ -209,7 +182,7 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(execution, "source")], - h.p(class_="mt-1 font-mono text-xs text-slate-500")[ + h.p(class_="mt-0.5 font-mono text-xs text-slate-500")[ _text(execution, "slug") ], ], @@ -220,13 +193,13 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: ], h.div[ ended_at_label, - h.p(class_="mt-1 text-xs text-slate-500")[_text(execution, "summary")], + h.p(class_="mt-0.5 text-xs text-slate-500")[_text(execution, "summary")], ], status_badge( label=_text(execution, "status"), tone=_text(execution, "status_tone"), ), - h.div(class_="min-w-48 whitespace-normal")[ + h.div(class_="max-w-[14rem] whitespace-normal")[ h.p(class_="font-medium text-slate-900")[_text(execution, "stats")] ], inline_link( diff --git a/repub/pages/settings.py b/repub/pages/settings.py index d2730c0..efe513d 100644 --- a/repub/pages/settings.py +++ b/repub/pages/settings.py @@ -5,7 +5,13 @@ from collections.abc import Mapping import htpy as h from htpy import Renderable -from repub.components import input_field, muted_action_link, page_shell, section_card +from repub.components import ( + action_button, + input_field, + muted_action_link, + page_shell, + section_card, +) def _value(settings: Mapping[str, object] | None, key: str, default: str = "") -> str: @@ -71,10 +77,12 @@ def settings_page( ], h.div(class_="flex flex-wrap justify-end gap-3 pt-2")[ muted_action_link(href="/", label="Back to dashboard"), - h.button( - type="submit", - class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", - )["Save settings"], + action_button( + label="Save settings", + tone="dark", + emphasis="regular", + button_type="submit", + ), ], ], ) diff --git a/repub/pages/shim.py b/repub/pages/shim.py index 1b8723f..e23ceac 100644 --- a/repub/pages/shim.py +++ b/repub/pages/shim.py @@ -3,7 +3,7 @@ from __future__ import annotations import htpy as h from htpy import Node, Renderable -from repub.components import admin_sidebar +from repub.components import app_shell ON_LOAD_JS = ( "@post(window.location.pathname + " @@ -33,43 +33,34 @@ def shim_page( } ), h.noscript["Your browser does not support JavaScript!"], - h.main( - id="morph", - class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]", - )[ - admin_sidebar( - current_path=current_path, - source_count=0, - running_count=0, - ), - h.div(class_="px-4 py-4 sm:px-5 lg:px-6 lg:py-5")[ - h.div(class_="mx-auto max-w-7xl space-y-5")[ - h.section[ - h.div( - class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" - )[ - h.div(class_="max-w-3xl")[ - h.p( - class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" - )["Connecting"], - h.h1( - class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" - )["Loading page"], - ], - ] - ], - h.section( - class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" + app_shell( + current_path=current_path, + content=( + h.section[ + h.div( + class_="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" )[ - h.div(class_="animate-pulse space-y-4 p-6")[ - h.div(class_="h-5 w-40 rounded-full bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - h.div(class_="h-12 rounded-2xl bg-stone-100"), - ] - ], - ] - ], - ], + h.div(class_="max-w-3xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-600" + )["Connecting"], + h.h1( + class_="mt-1 text-3xl font-semibold tracking-tight text-slate-950" + )["Loading page"], + ], + ] + ], + h.section( + class_="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200" + )[ + h.div(class_="animate-pulse space-y-4 p-6")[ + h.div(class_="h-5 w-40 rounded-full bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + h.div(class_="h-12 rounded-2xl bg-stone-100"), + ] + ], + ), + ), ], ] diff --git a/repub/pages/sources.py b/repub/pages/sources.py index ad0c93a..93c625f 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -6,6 +6,7 @@ import htpy as h from htpy import Node, Renderable from repub.components import ( + action_button, header_action_link, inline_link, input_field, @@ -54,29 +55,6 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo return bool(value) -def _action_button( - *, - label: str, - tone: str = "default", - post_path: str | None = None, -) -> Renderable: - classes = { - "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", - "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", - } - attributes: dict[str, str] = {} - if post_path is not None: - attributes["data-on:pointerdown"] = f"@post('{post_path}')" - return h.button( - attributes, - type="button", - class_=( - "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " - f"text-sm font-semibold transition {classes[tone]}" - ), - )[label] - - def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -99,12 +77,12 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: ), h.p(class_="mt-2 text-xs text-slate-500")[str(source["last_run"])], ], - h.div(class_="flex flex-nowrap items-center gap-3")[ + h.div(class_="flex flex-wrap items-center gap-2")[ inline_link( href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" ), inline_link(href="/runs", label="View runs"), - _action_button( + action_button( label="Delete", tone="danger", post_path=f"/actions/sources/{source['slug']}/delete", @@ -422,10 +400,12 @@ def source_form( class_="flex flex-wrap justify-end gap-3 border-t border-slate-200 pt-6" )[ muted_action_link(href="/sources", label="Cancel"), - h.button( - type="submit", - class_="rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800", - )[submit_label], + action_button( + label=submit_label, + tone="dark", + emphasis="regular", + button_type="submit", + ), ], ], ) diff --git a/repub/static/app.css b/repub/static/app.css index 9bb5f7e..659b7c2 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -42,6 +42,7 @@ --color-stone-200: oklch(92.3% 0.003 48.717); --color-white: #fff; --spacing: 0.25rem; + --container-xs: 20rem; --container-sm: 24rem; --container-3xl: 48rem; --container-7xl: 80rem; @@ -289,8 +290,8 @@ .mt-5 { margin-top: calc(var(--spacing) * 5); } - .mt-10 { - margin-top: calc(var(--spacing) * 10); + .mt-8 { + margin-top: calc(var(--spacing) * 8); } .mt-auto { margin-top: auto; @@ -320,9 +321,9 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } - .size-11 { - width: calc(var(--spacing) * 11); - height: calc(var(--spacing) * 11); + .size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); } .h-5 { height: calc(var(--spacing) * 5); @@ -354,30 +355,33 @@ .max-w-7xl { max-width: var(--container-7xl); } - .max-w-40 { - max-width: calc(var(--spacing) * 40); + .max-w-32 { + max-width: calc(var(--spacing) * 32); + } + .max-w-\[14rem\] { + max-width: 14rem; } .max-w-sm { max-width: var(--container-sm); } + .max-w-xs { + max-width: var(--container-xs); + } .min-w-32 { min-width: calc(var(--spacing) * 32); } - .min-w-48 { - min-width: calc(var(--spacing) * 48); - } .min-w-56 { min-width: calc(var(--spacing) * 56); } .min-w-64 { min-width: calc(var(--spacing) * 64); } + .min-w-\[64rem\] { + min-width: 64rem; + } .min-w-\[70rem\] { min-width: 70rem; } - .min-w-\[72rem\] { - min-width: 72rem; - } .shrink-0 { flex-shrink: 0; } @@ -427,6 +431,9 @@ .gap-2 { gap: calc(var(--spacing) * 2); } + .gap-2\.5 { + gap: calc(var(--spacing) * 2.5); + } .gap-3 { gap: calc(var(--spacing) * 3); } @@ -552,6 +559,9 @@ .bg-slate-200 { background-color: var(--color-slate-200); } + .bg-slate-300 { + background-color: var(--color-slate-300); + } .bg-slate-800 { background-color: var(--color-slate-800); } @@ -649,9 +659,6 @@ .px-4 { padding-inline: calc(var(--spacing) * 4); } - .px-6 { - padding-inline: calc(var(--spacing) * 6); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -673,6 +680,9 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } .py-8 { padding-block: calc(var(--spacing) * 8); } @@ -682,9 +692,15 @@ .pt-6 { padding-top: calc(var(--spacing) * 6); } + .pr-5 { + padding-right: calc(var(--spacing) * 5); + } .pr-6 { padding-right: calc(var(--spacing) * 6); } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } .pl-4 { padding-left: calc(var(--spacing) * 4); } @@ -820,6 +836,12 @@ .text-white { color: var(--color-white); } + .text-white\/80 { + color: color-mix(in srgb, #fff 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-white) 80%, transparent); + } + } .uppercase { text-transform: uppercase; } @@ -879,6 +901,11 @@ color: var(--color-slate-400); } } + .first\:pl-3 { + &:first-child { + padding-left: calc(var(--spacing) * 3); + } + } .first\:pl-4 { &:first-child { padding-left: calc(var(--spacing) * 4); @@ -1036,30 +1063,25 @@ justify-content: space-between; } } + .sm\:px-4 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 4); + } + } .sm\:px-5 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 5); } } - .sm\:px-6 { - @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 6); - } - } .sm\:pl-4 { @media (width >= 40rem) { padding-left: calc(var(--spacing) * 4); } } - .sm\:pl-6 { - @media (width >= 40rem) { - padding-left: calc(var(--spacing) * 6); - } - } - .sm\:first\:pl-6 { + .sm\:first\:pl-4 { @media (width >= 40rem) { &:first-child { - padding-left: calc(var(--spacing) * 6); + padding-left: calc(var(--spacing) * 4); } } } @@ -1088,16 +1110,31 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:grid-cols-\[14rem_minmax\(0\,1fr\)\] { + @media (width >= 64rem) { + grid-template-columns: 14rem minmax(0,1fr); + } + } .lg\:grid-cols-\[18rem_minmax\(0\,1fr\)\] { @media (width >= 64rem) { grid-template-columns: 18rem minmax(0,1fr); } } + .lg\:px-5 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 5); + } + } .lg\:px-6 { @media (width >= 64rem) { padding-inline: calc(var(--spacing) * 6); } } + .lg\:py-4 { + @media (width >= 64rem) { + padding-block: calc(var(--spacing) * 4); + } + } .lg\:py-5 { @media (width >= 64rem) { padding-block: calc(var(--spacing) * 5); diff --git a/tests/test_web.py b/tests/test_web.py index da4daef..2b9c61a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast -from repub.components import status_badge, toggle_field +from repub.components import action_button, status_badge, toggle_field from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.jobs import load_dashboard_view from repub.model import ( @@ -61,6 +61,52 @@ def test_toggle_field_active_state_utilities_exist_in_built_css() -> None: assert ".translate-x-5" in css +def test_action_button_adds_cursor_pointer_for_active_buttons() -> None: + markup = str(action_button(label="Run now")) + + assert "cursor-pointer" in markup + assert 'type="button"' in markup + + +def test_action_button_omits_post_handler_when_disabled() -> None: + markup = str( + action_button( + label="Queued", + disabled=True, + post_path="/actions/jobs/7/run-now", + ) + ) + + assert "cursor-not-allowed" in markup + assert "@post(" not in markup + + +def test_action_button_supports_submit_variant() -> None: + markup = str( + action_button( + label="Save settings", + tone="dark", + button_type="submit", + ) + ) + + assert 'type="submit"' in markup + assert "bg-slate-950" in markup + assert "cursor-pointer" in markup + + +def test_action_button_supports_datastar_pointerdown_post() -> None: + markup = str( + action_button( + label="Delete", + tone="danger", + post_path="/actions/jobs/7/delete", + ) + ) + + assert 'data-on:pointerdown="@post('/actions/jobs/7/delete')"' in markup + + def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> ( None ): @@ -140,6 +186,8 @@ def test_root_get_serves_datastar_shim() -> None: assert "retryMaxCount: Infinity" in body assert "data-on:online__window=" in body assert '
None: + db_path = tmp_path / "runs-compact.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + body = str(await render_runs(app)) + + assert "lg:grid-cols-[14rem_minmax(0,1fr)]" in body + assert "lg:px-5 lg:py-4" in body + assert "min-w-[64rem]" in body + + asyncio.run(run()) + + def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "runs-empty.db" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) @@ -1231,6 +1300,87 @@ def test_render_runs_shows_cancel_button_for_running_row_with_queued_follow_up( asyncio.run(run()) +def test_render_runs_keeps_all_action_controls_visible_in_html_after_compaction() -> ( + None +): + body = str( + runs_page( + running_executions=( + { + "source": "Running source", + "slug": "running-source", + "job_id": 1, + "execution_id": 11, + "started_at": "2026-03-30 12:00 UTC", + "runtime": "running for 10s", + "status": "Running", + "stats": "1 requests • 1 items • 1 byte", + "worker": "streaming stats from worker", + "log_href": "/job/1/execution/11/logs", + "cancel_label": "Stop", + "cancel_post_path": "/actions/executions/11/cancel", + }, + ), + queued_executions=( + { + "source": "Queued source", + "slug": "queued-source", + "job_id": 2, + "execution_id": 22, + "queued_at": "2 minutes ago", + "queued_at_iso": "2026-03-30T12:28:00+00:00", + "queue_position": 1, + "status": "Queued", + "status_tone": "idle", + "run_label": "Queued", + "run_disabled": True, + "run_post_path": "/actions/jobs/2/run-now", + "cancel_post_path": "/actions/queued-executions/22/cancel", + }, + ), + upcoming_jobs=( + { + "source": "Scheduled source", + "slug": "scheduled-source", + "job_id": 3, + "next_run": "in 5 minutes", + "next_run_at": "2026-03-30T12:35:00+00:00", + "schedule": "*/5 * * * *", + "enabled_label": "Enabled", + "enabled_tone": "scheduled", + "run_disabled": False, + "run_reason": "Ready", + "toggle_label": "Disable", + "toggle_post_path": "/actions/jobs/3/toggle-enabled", + "run_post_path": "/actions/jobs/3/run-now", + "delete_post_path": "/actions/jobs/3/delete", + }, + ), + completed_executions=( + { + "source": "Completed source", + "slug": "completed-source", + "job_id": 4, + "execution_id": 44, + "ended_at": "2 minutes ago", + "ended_at_iso": "2026-03-30T12:28:00+00:00", + "status": "Succeeded", + "status_tone": "done", + "stats": "1 requests • 1 items • 1 byte", + "summary": "Worker exited successfully", + "log_href": "/job/4/execution/44/logs", + }, + ), + ) + ) + + assert ">Stop<" in body + assert ">Cancel<" in body + assert ">Run now<" in body + assert ">Disable<" in body + assert "/job/4/execution/44/logs" in body + + def test_cancel_queued_execution_action_deletes_pending_row_without_touching_running_execution( monkeypatch, tmp_path: Path ) -> None: @@ -1327,6 +1477,24 @@ def test_toggle_job_enabled_action_removes_queued_execution( asyncio.run(run()) +def test_render_create_source_uses_shared_submit_button( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "create-source-shared-submit.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + body = str(await render_create_source(app)) + + assert 'type="submit"' in body + assert "Create source" in body + assert "cursor-pointer" in body + assert "bg-slate-950" in body + + asyncio.run(run()) + + def test_render_execution_logs_uses_app_route(monkeypatch, tmp_path: Path) -> None: db_path = tmp_path / "logs-render.db" monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))