Compare commits

..

4 commits

Author SHA1 Message Date
ThePetrovich
c86f583a15 bump version 2026-08-29 22:06:06 +08:00
ThePetrovich
fafd9320ab Update bootstrap 2026-08-23 15:26:38 +08:00
ThePetrovich
f18276ab01 Comment cleanup 2026-08-21 22:09:17 +08:00
ThePetrovich
c6cbc8fe70 Bump version 2026-08-18 21:52:27 +08:00
12 changed files with 413 additions and 66 deletions

View file

@ -1,6 +1,7 @@
# yksa-web-kit
Common UI modules and packages (navbar, chrome, vendored libs). Should be used across all new projects in YKSA TM/TC family.
Common UI: navbar, page chrome, and vendored front-end libs, shared across the
YKSA TM/TC services.
## Install

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "yksa-web-kit"
version = "0.1.0"
version = "0.1.3"
description = "Shared chrome, UI kit and vendored front-end assets for the YKSA services"
requires-python = ">=3.13"
license = { text = "Proprietary" }

View file

@ -42,7 +42,7 @@ def test_the_navbar_and_footer_are_on_every_page():
@pytest.mark.django_db
def test_the_service_cannot_reorder_the_chrome():
"""navbar, then main, then footer a service that wants otherwise has to
"""navbar, then main, then footer: a service that wants otherwise has to
stop extending the kit, which is the point."""
html = Client().get("/privacy/").content.decode()
assert html.index("<nav") < html.index("<main") < html.index("<footer")
@ -94,6 +94,24 @@ def test_page_title_links_its_parent():
assert "W-1" in html
def test_page_title_walks_the_whole_trail_when_given_one():
"""A page two levels down names its grandparent, so the trail reaches the
section root instead of starting halfway along it."""
html = render_to_string("yksa/ui/_page_title.html", {
"title": "Status", "parent": "W-1", "parent_url": "/widgets/w-1/",
"grandparent": "Widgets", "grandparent_url": "/widgets/",
})
assert html.index("/widgets/\"") < html.index("/widgets/w-1/")
assert html.count("<a ") == 2
def test_page_title_without_a_grandparent_is_unchanged():
html = render_to_string("yksa/ui/_page_title.html", {
"title": "W-1", "parent": "Widgets", "parent_url": "/widgets/",
})
assert html.count("<a ") == 1
# --- the estate-wide rules --------------------------------------------------

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
/* =============================================================================
YKSA UI kit. Ships with yksa-web-kit; see ecosystem/ui-kit.md.
YKSA UI kit.
Requires Bootstrap >= 5.3: the state pills below are built on the
--bs-*-bg-subtle / --bs-*-text-emphasis / --bs-*-border-subtle families, and
@ -34,6 +34,23 @@
--yksa-z-sticky: 1020;
--yksa-z-chrome: 1030;
--yksa-z-floating: 1035;
/* One hover treatment for the whole estate: the surface's ground goes one
step darker and nothing else moves. Every hoverable thing -- table rows,
list rows, step rows, catalog cards, disclosures -- uses this token, so a
page cannot end up with three ways of saying "you are pointing at this".
Borders, shadows and colour shifts are all reserved for *state*, which is
a property of the record; hover is a property of the pointer. */
--yksa-hover-bg: var(--bs-tertiary-bg);
}
/* Bootstrap's own hoverable components, pointed at the same token. */
.table {
--bs-table-hover-bg: var(--yksa-hover-bg);
}
.list-group {
--bs-list-group-action-hover-bg: var(--yksa-hover-bg);
}
/* Bootstrap declares --bs-dropdown-zindex on .dropdown-menu, not on :root, so it
@ -43,10 +60,6 @@
--bs-dropdown-zindex: var(--yksa-z-floating);
}
/* -----------------------------------------------------------------------------
Navbar and footer -- layout-identical across every service
-------------------------------------------------------------------------- */
.custom-navbar {
height: var(--navbar-height);
padding-top: 0;
@ -96,20 +109,54 @@
object-fit: contain;
}
/* An open dropdown is as much "the item you are on" as an active one. Without
this the toggle dropped back to the resting colours the moment its own menu
appeared, which reads as the click having missed. */
.nav-full-height.nav-link:hover,
.nav-full-height.nav-link.active {
.nav-full-height.nav-link.active,
.nav-full-height.nav-link.show {
color: #fff !important;
background-color: var(--bs-primary);
}
/* -----------------------------------------------------------------------------
State -- the sanctioned rendering of status (ui/_state.html)
-------------------------------------------------------------------------- */
/* Muted text inside a highlighted nav item takes the item's colour.
Bootstrap's text utilities set `color` with `!important`, so a muted span --
a countdown, a hint -- kept its resting grey when the item turned solid primary
underneath it: grey on blue, the one combination the theme has no contrast for.
`inherit` needs `!important` here for the same reason the utility does.
Deliberately only the muted utility, not every descendant. A badge and a state
pill are separate surfaces carrying their own background, and flattening them
to the parent's colour would erase what they are for. */
.nav-full-height.nav-link:hover .text-body-secondary,
.nav-full-height.nav-link.active .text-body-secondary,
.nav-full-height.nav-link.show .text-body-secondary,
/* Same problem, same fix, one row down: a dropdown item with a muted hint or
sub-line under its label -- the outcome menu on the pass console, the duty
menu in the header -- highlights on hover, focus and keyboard selection, and
the muted line stayed grey against it. Any menu that explains its options has
this shape, so the rule belongs on `.dropdown-item` rather than on each one. */
.dropdown-item:hover .text-body-secondary,
.dropdown-item:focus .text-body-secondary,
.dropdown-item:active .text-body-secondary,
.dropdown-item.active .text-body-secondary {
color: inherit !important;
}
/* `inline-block`, not `inline-flex`, and this is the whole of the table-alignment
fix. An inline-flex box's baseline is its last flex item's baseline, and its
padding and border hang *below* that -- so in a table cell the pill sits low,
and no amount of `vertical-align` corrects it without also fighting the line
height it inherits. An inline-block shares the row's baseline with the text
beside it and straddles it symmetrically, which is why Bootstrap's own `.badge`
has always looked right in a table. The icon is centred by bootstrap-icons'
`vertical-align: -.125em`, the same way it is in running text.
Nothing here sets `line-height`: the pill takes the surface's, so it matches
the row it sits in rather than being a second type size. */
.yksa-state {
display: inline-flex;
align-items: center;
gap: .25rem;
display: inline-block;
font-size: .75rem;
font-weight: 700;
padding: .05rem .4rem;
@ -117,7 +164,11 @@
border: 1px solid transparent;
}
/* health */
.yksa-state > .bi,
.yksa-chip > .bi {
margin-right: .25rem;
}
.yksa-state-ok,
.yksa-state-complete {
color: var(--bs-success-text-emphasis);
@ -153,11 +204,10 @@
border-color: var(--bs-border-color);
}
/* Neutral metadata chip. Not a coloured badge, because it carries no status. */
/* Neutral metadata chip. Not a coloured badge, because it carries no status.
Same box model as .yksa-state, for the same reason. */
.yksa-chip {
display: inline-flex;
align-items: center;
gap: .25rem;
display: inline-block;
font-size: .75rem;
padding: 0 .35rem;
white-space: nowrap;
@ -166,9 +216,39 @@
border: 1px solid var(--bs-border-color);
}
/* -----------------------------------------------------------------------------
Typography helpers
-------------------------------------------------------------------------- */
/* A borderless icon button for dense rows: a table row's remove control, an
overflow `...` toggle, a field's clear. Bordered buttons in those places draw a
box around every row and the boxes become the pattern the eye follows.
This exists because the alternative was being spelled out by hand, differently,
in five templates -- `btn btn-sm btn-link p-0 text-body-secondary`, sometimes
with `border-0`, sometimes with `px-1`, once with an `fs-5` on the glyph. It is
a button, not a link: `.btn-link` is reserved for navigation, and a control
styled as a link but wired to a POST invites a middle-click that silently
does nothing. */
.yksa-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
min-height: 1.75rem;
padding: 0 .25rem;
color: var(--bs-secondary-color);
background: none;
border: none;
line-height: 1;
}
.yksa-btn-icon:hover,
.yksa-btn-icon:focus-visible {
color: var(--bs-body-color);
background: var(--yksa-hover-bg);
}
.yksa-btn-icon.is-danger:hover,
.yksa-btn-icon.is-danger:focus-visible {
color: var(--bs-danger);
}
.yksa-label {
font-size: .75rem;
@ -202,6 +282,36 @@
margin-inline: 0.3rem;
}
/* A run of metadata separated by rules. Use this rather than dropping bare
`.yksa-sep`s between text nodes.
The margins above are symmetric, but the *source* around them is not: a
template that puts the separator at the start of a line contributes a collapsed
space on its left and none on its right, so the rule sits visibly off-centre
between the two items it divides. Nobody sees that while writing the template
and everybody sees it on the page.
A flex container fixes it at the root: whitespace-only text nodes never become
flex items, so indentation stops existing, and the gap is the only spacing.
Each item goes in its own element -- an unwrapped text node *would* become an
anonymous flex item, and then the whole run is one item and the gap does
nothing. That is the reason for the spans. */
.yksa-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 .5rem;
}
.yksa-meta-tight {
gap: 0 .3rem;
}
/* Inside the run the gap does the spacing; the rule's own margins would double it. */
.yksa-meta > .yksa-sep {
margin-inline: 0;
}
/* Tabular numerals in the body face. Monospace is reserved for verbatim machine
text -- hex dumps, TLE lines, command mnemonics. */
.yksa-num {
@ -223,9 +333,92 @@
font-size: .8em;
}
/* -----------------------------------------------------------------------------
Layout
-------------------------------------------------------------------------- */
/* A container that supplies its own padding must not also inherit the trailing
margin of whatever it ends with. Bootstrap does this for `p` in a few places
and not at all for `dl`, `ul` or `table`, so the fix was being applied by hand
-- an `mb-0` on the last element of every card body -- which every element
added later then has to remember. It is one rule and it belongs here.
Scoped to direct children: anything deeper is inside something else's box, and
that box's spacing is its own business. */
.card-body > :last-child,
.alert > :last-child,
.list-group-item > :last-child,
.modal-body > :last-child,
.offcanvas-body > :last-child {
margin-bottom: 0;
}
/* A definition list ends with a `dd`, which carries a bottom margin of its own.
`mb-0` on the `dl` does not reach it, so the gap survives the very utility
applied to remove it -- which is how several fact lists ended up looking
bottom-heavy inside an otherwise even card. */
dl:last-child > dd:last-child {
margin-bottom: 0;
}
/* Alerts here paint in solid colour, not Bootstrap's default tinted panel: a
utility text colour computed against the page ground is unreadable on a
saturated one, and an outline button drawn in `--bs-secondary` all but
vanishes -- hence the overrides below.
Muted text inside an alert is the alert's own colour, softened. All three of
these resolve to --bs-secondary-color, which is computed against the page. */
.alert .text-body-secondary,
.alert .yksa-label,
.alert .yksa-hint {
color: currentColor !important;
opacity: .8;
}
/* Outline buttons take their colour from the alert rather than from the palette,
and invert to the alert's ground on hover. The semantic distinction between
`-secondary` and `-danger` is lost here, which is correct: the alert already
says which of those it is, and a red outline on a red ground says nothing.
Every value comes from `--bs-alert-color` / `--bs-alert-bg`, which the alert
variant declares and the button inherits, so one rule covers all six variants
in both text directions. Not `currentColor`: inside `:hover` that resolves to
the *hover* colour, so a hover background of `currentColor` paints the button
the colour it is about to be and the hover state disappears. */
.alert .btn-outline-primary,
.alert .btn-outline-secondary,
.alert .btn-outline-success,
.alert .btn-outline-danger,
.alert .btn-outline-warning {
--bs-btn-color: var(--bs-alert-color);
--bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-disabled-color: var(--bs-alert-color);
--bs-btn-disabled-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255;
}
/* A filled button on a solid alert is the alert's own colour inverted -- the same
pair, the other way round. `btn-light` was doing this by hand and only happened
to be right on the four dark variants. */
.alert .btn-contrast {
--bs-btn-color: var(--bs-alert-bg);
--bs-btn-bg: var(--bs-alert-color);
--bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255;
opacity: 1;
}
.alert .btn-contrast:hover {
opacity: .9;
}
/* A record in a list is a row with a left status marker, not a card. */
.yksa-row {
@ -237,19 +430,107 @@
}
.yksa-row:hover {
background: var(--bs-tertiary-bg);
background: var(--yksa-hover-bg);
}
.yksa-row.is-in-progress { border-left-color: var(--bs-primary); }
.yksa-row.is-complete { border-left-color: var(--bs-success); }
.yksa-row.is-pending { border-left-color: var(--bs-warning); }
.yksa-row.is-failed { border-left-color: var(--bs-danger); }
/* Withdrawn/skipped: still a record, but nothing here is work. The border
stays the resting grey and the row's text steps back, which is the same
thing `skipped` says in the state vocabulary. */
.yksa-row.is-skipped { color: var(--bs-secondary-color); }
/* The one saturated element allowed on a surface: the active shift, the pass in
contact, the source currently polling. Two on a page means one is wrong. */
/* A table row that *is* a record: the row is the link to it. Sanctioned where a
card's equivalent is not -- a row is one target, while a card is a container
of several. `.stretched-link` goes on the cell that names the record; this
class is only here to give that anchor something to stretch against.
Sibling links in the row are lifted above it, the same lift `.card-footer`
needed and for the same reason: the stretched anchor covers the whole row, so
without this a press on the station's own link silently opens the record
instead. A row with no sibling links needs no lift and pays nothing for it. */
.yksa-row-link {
position: relative;
}
.yksa-row-link a:not(.stretched-link) {
position: relative;
z-index: 2;
}
/* The one emphasised element allowed on a surface: the active shift, the pass in
contact, the source currently polling. Two on a page means one is wrong.
It is a heavier border in the accent colour, not a tint. A row already carries
a coloured left border and usually a state pill; adding a filled ground makes
three ways of saying one thing, and the strongest of the three drowns the row's
own content. Emphasis is the *weight* of the existing signal, not a new one. */
.yksa-row.is-active {
border-left-color: var(--bs-success);
background: var(--bs-success-bg-subtle);
border-left-width: 6px;
padding-left: calc(.5rem - 2px);
}
/* Catalog card: a card summarising one record. The card is a container, not a
control: its title is the link and nothing else in it moves under the pointer.
Two earlier versions made the whole card hot -- one recoloured the border and
title, one painted the hover ground -- and both put a card-sized reaction
behind a card-sized target that also held its own footer buttons, so the page
read as a grid of giant buttons and a press near a footer control was a coin
toss. Hover is reserved for rows, which are one target each.
`position: relative` stays so a card can still host absolutely-positioned
corner chrome; it no longer backs a .stretched-link. */
.yksa-card-link {
position: relative;
}
/* The title is a link and says so in the link colour, the way odms has always
drawn it. `.text-decoration-none` on the anchor suppresses the underline at
rest so a grid of forty titles is not forty underlines; hover brings it back,
which is this card's one hover affordance. Both rules have to out-specify that
utility, hence the element-qualified selectors. */
.yksa-card-link .card-title a,
.yksa-card-link h2 a,
.yksa-card-link h3 a {
color: var(--bs-link-color);
}
.yksa-card-link .card-title a:hover,
.yksa-card-link h2 a:hover,
.yksa-card-link h3 a:hover,
.yksa-card-link .card-title a:focus-visible,
.yksa-card-link h2 a:focus-visible,
.yksa-card-link h3 a:focus-visible {
color: var(--bs-link-hover-color);
text-decoration: underline;
}
/* Row chrome that appears under the pointer: the retract on one entry of a long
append-only stream, where forty always-visible trash icons would be the loudest
thing on the page. It keeps its box at rest, so revealing it never reflows the
row. Focus counts as pointing -- without that clause the control exists only
for mouse users. */
.yksa-reveal {
opacity: 0;
}
.yksa-reveal-host:hover .yksa-reveal,
.yksa-reveal:focus-visible,
.yksa-reveal-host:focus-within .yksa-reveal {
opacity: 1;
}
/* The fact grid inside a catalog card: two columns of `.yksa-label` + figure.
It was `.meta-col` in odms.css, defined against `.sat-row-card`, which is why
ops built its own with a `dl.row` instead and got a different rhythm and a
stray bottom margin. One card, one grid. */
.yksa-card-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .25rem .75rem;
}
/* Empty state: a sentence plus, where there is one, the action that resolves
@ -286,10 +567,6 @@
margin-bottom: 0;
}
/* -----------------------------------------------------------------------------
Cookie banner
-------------------------------------------------------------------------- */
.cookie-banner {
position: sticky;
bottom: 0;
@ -304,10 +581,6 @@
color: #bfdbfe;
}
/* -----------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 991px) {
/* Collapsed, the nav is a stacked list; the full-height tab treatment would
put a border around every row. */
@ -317,40 +590,61 @@
border: none !important;
}
/* The bar grows here (the brand block keeps its height and the padding is
added around it), and --navbar-height has to grow with it: it is what
every in-page sticky bar offsets itself by, and a stale 44px slid ops's
pass console header up under the navbar on tablet widths.
61px is the same arithmetic that produces 44px above -- 34px logo + the
brand block's .3125rem padding either side -- plus .5rem of bar padding
either side and the 1px bottom border. min-height, not height, so a taller
brand overflows the offset rather than being clipped by it. */
:root {
--navbar-height: 61px;
}
.custom-navbar {
height: auto;
min-height: var(--navbar-height);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
/* -----------------------------------------------------------------------------
Chip filters (yksa/includes/chip_filters.html + src/chip-filters.js)
-------------------------------------------------------------------------- */
/* A filter chip is a toggle, so it looks like one at both ends of its state: an
outlined chip at rest, a filled one when it is on. It used to be `badge
bg-secondary` at rest, which made an unselected filter a solid grey block --
as loud as the selected one and easy to read as already applied. The class is
self-contained now; callers need no `badge bg-*` alongside it. */
.filter-chip {
display: inline-block;
font-size: .75rem;
line-height: 1.5;
padding: .05rem .5rem;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
cursor: pointer;
user-select: none;
transition: background-color 0.12s ease;
}
.filter-chip:hover {
filter: brightness(110%);
background-color: var(--yksa-hover-bg);
}
.filter-chip.active {
background-color: var(--bs-primary) !important;
color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
/* -----------------------------------------------------------------------------
Prose and tables
Long-form text rendered from the database (policies, descriptions) and the
table cells that hold it. Everything else that used to live in main.css was
qsl-only and did not come across.
-------------------------------------------------------------------------- */
.filter-chip.active:hover {
background-color: var(--bs-primary);
}
/* Long-form text rendered from the database (policies, descriptions) and the
table cells that hold it. */
.text-content {
overflow: auto;
hyphens: auto;
@ -376,6 +670,10 @@ td ul:last-child {
--bs-alert-border-color: #c42526;
}
/* A quiet border for grouped buttons that sit inside another bordered surface --
a catalog card's footer -- where the button group's own outline would read as a
second box around the first. `--bs-border-color` rather than a black alpha: the
alpha approximated exactly this colour on white and disappeared on anything else. */
.btn-outline-bordered {
border-color: rgba(0, 0, 0, 0.066);
border-color: var(--bs-border-color);
}

View file

@ -42,6 +42,9 @@
active.push(chip.dataset.value);
});
hidden.value = active.join(",");
// Live surfaces (a server-side table, a chart) listen here rather than
// waiting for a submit. A hidden input never fires `change` on its own.
hidden.dispatchEvent(new Event("change", { bubbles: true }));
}
document.querySelectorAll("[data-filter-group]").forEach(function (group) {
@ -49,6 +52,7 @@
var chip = event.target.closest(".filter-chip");
if (!chip || !group.contains(chip)) return;
chip.classList.toggle("active");
chip.setAttribute("aria-pressed", chip.classList.contains("active") ? "true" : "false");
syncHidden(group);
});
});

View file

@ -3,7 +3,7 @@ The estate skeleton. Every service's own base.html extends this and fills the
blocks; nothing here is meant to be copied into a service.
The navbar is written out here rather than {% include %}d because blocks inside
an included template cannot be overridden and the navbar's layout has to be
an included template cannot be overridden, and the navbar's layout has to be
identical everywhere while its items differ per service. Same for the footer's
position. Fill `navbar_items` and `navbar_tools`; change nothing else.
@ -38,7 +38,13 @@ Blocks a service normally fills:
<link rel="stylesheet" href="{% static 'yksa/css/kit.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="d-flex flex-column min-vh-100 bg-light">
{% comment %}
`bg-body-tertiary`, not `bg-light`. `bg-light` is a fixed near-white that does
not follow `data-bs-theme`, so it was the one element standing between the
estate and a working dark theme: every card and control below it recoloured
and the page they sat on did not.
{% endcomment %}
<body class="d-flex flex-column min-vh-100 bg-body-tertiary">
<nav class="navbar navbar-expand-lg custom-navbar border-bottom sticky-top">
<div class="{% block navbar_container_class %}container{% endblock navbar_container_class %}">
<a class="navbar-brand nav-full-height" href="{% block brand_url %}/{% endblock brand_url %}">
@ -163,7 +169,6 @@ Blocks a service normally fills:
{% include "yksa/includes/_footer.html" %}
<script src="{% static 'yksa/src/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'yksa/src/cookie-banner.js' %}"></script>
{% comment %}Shows the toasts yksa/includes/_messages.html renders.{% endcomment %}
<script src="{% static 'yksa/src/toast.js' %}"></script>
{% block extra_js %}{% endblock extra_js %}
</body>

View file

@ -17,10 +17,14 @@ Optional context:
* empty_message: text shown when ``chips`` is empty (defaults to a generic
placeholder via {{ empty_message|default:_("No options yet.") }}).
Each chip is a real <button>, so it is focusable and answers to Enter/Space; a
<span> was reachable by mouse only.
The accompanying JS (static/src/chip-filters.js) toggles ``.filter-chip.active``
on click and re-emits the comma-separated value into the hidden input keyed by
``data-filter-csv``. Submitting the form sends the result; servers split on
comma. No XHR -- keeps URLs bookmarkable.
``data-filter-csv``, then fires `change` on it so a live table can redraw without
a submit. Submitting the form sends the result; servers split on comma. No XHR by
default -- keeps URLs bookmarkable.
{% endcomment %}
{% load i18n %}
<div class="card mb-3">
@ -29,8 +33,9 @@ comma. No XHR -- keeps URLs bookmarkable.
{% if chips %}
<div class="d-flex flex-wrap gap-1" data-filter-group="{{ name }}">
{% for chip in chips %}
<span class="badge bg-secondary filter-chip {% if chip.selected %}active{% endif %}"
data-value="{{ chip.value }}">{{ chip.label }}</span>
<button type="button" class="filter-chip {% if chip.selected %}active{% endif %}"
aria-pressed="{% if chip.selected %}true{% else %}false{% endif %}"
data-value="{{ chip.value }}">{{ chip.label }}</button>
{% endfor %}
</div>
{% else %}

View file

@ -12,9 +12,14 @@ KPI tile. One column of a .row-cols-* grid.
A tile whose secondary line is itself markup (a timestamp through
yksa/ui/_utc.html, say) builds the same three elements inline with the same
classes, as with .yksa-empty.
It is an ordinary .card. It used to be `border-0 bg-white shadow-sm`, which was a
raised white panel in a kit whose every other surface is a bordered flat one, and
`bg-white` is a hard-coded light-theme colour besides -- wrong in a dark theme on
every service. Depth is not a level of hierarchy this kit has.
{% endcomment %}
<div class="col">
<div class="card h-100 border-0 bg-white shadow-sm">
<div class="card h-100">
<div class="card-body py-2 px-3{% if accent %} border-start border-{{ accent }} border-3{% endif %}">
<div class="yksa-label text-truncate">{{ label }}</div>
<div class="yksa-readout fs-4">{{ value }}</div>

View file

@ -5,9 +5,14 @@ fixed. Never pick a heading level for its size.
{% trans "Widgets" as parent %}{% url 'public:widget-list' as parent_url %}
{% include "yksa/ui/_page_title.html" with title=widget.name parent=parent parent_url=parent_url %}
A page one level deeper names its grandparent too, so the trail reaches the
section root rather than starting halfway down it:
{% include "yksa/ui/_page_title.html" with title=_("Status") parent=widget.name parent_url=... grandparent=_("Widgets") grandparent_url=... %}
Put it inside the page header's flex row; the row owns the actions on the right.
{% endcomment %}
<h1 class="h3 mb-0">
{% if parent %}<a class="text-decoration-none" href="{{ parent_url }}">{{ parent }}</a> / {% endif %}{{ title }}
{% if grandparent %}<a class="text-decoration-none" href="{{ grandparent_url }}">{{ grandparent }}</a> / {% endif %}{% if parent %}<a class="text-decoration-none" href="{{ parent_url }}">{{ parent }}</a> / {% endif %}{{ title }}
</h1>
{% if subtitle %}<span class="text-body-secondary small">{{ subtitle }}</span>{% endif %}

View file

@ -15,7 +15,7 @@ here. A conditional chain choosing a colour inside a template is the bug this
partial exists to prevent: it is how the same run status ended up green on one
page and grey on another.
{% endcomment %}{% state state as s %}{% spaceless %}
<span class="yksa-state yksa-state-{{ state|default:'unknown' }}"{% if title %} title="{{ title }}"{% endif %}>
<span class="yksa-state yksa-state-{{ s.name }}"{% if title %} title="{{ title }}"{% endif %}>
<i class="bi bi-{{ s.icon }}" aria-hidden="true"></i>
<span>{{ label|default:s.label }}</span>
</span>

View file

@ -7,6 +7,12 @@ register = template.Library()
@register.simple_tag
def state(name):
"""Resolve a state name to {variant, icon, label} for ui/_state.html."""
variant, icon, label = STATES.get(name or "", STATES["unknown"])
return {"variant": variant, "icon": icon, "label": label}
"""Resolve a state name to {name, variant, icon, label} for ui/_state.html.
`name` is the *resolved* key, not what was asked for: the template builds its
`.yksa-state-<name>` class from it, and building that from the raw input meant
an unrecognised status got a class no stylesheet defines -- a blank pill, which
is the one outcome the fallback exists to prevent."""
key = name if name in STATES else "unknown"
variant, icon, label = STATES[key]
return {"name": key, "variant": variant, "icon": icon, "label": label}