diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css new file mode 100644 index 0000000..2c28abc --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css @@ -0,0 +1,191 @@ +/* ZB.MOM.WW.Theme — side-rail + login layout + Tokens live in theme.css; this sheet carries only layout + the side rail. */ + +/* ── App shell: side rail + page ─────────────────────────────────────────── */ +/* The outer flex direction is supplied by Bootstrap utilities on the wrapper + (`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above + the rail on summary { list-style: none; cursor: pointer; } +.rail-section > summary::-webkit-details-marker { display: none; } +.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; } +.rail-section[open] > summary::before { content: '\25BC'; } + +/* StatusPill: info variant (on-palette, reuses dir-read colours) */ +.chip-info { color: var(--accent-deep); background: #e7ecfb; border-color: #cdd9f7; } + +/* TechCard body/footer padding; TechField error; LoginCard body */ +.panel-body { padding: 0.85rem 0.9rem; } +.panel-foot { padding: 0.6rem 0.9rem; border-top: 1px solid var(--rule); } +.login-body { padding: 1.4rem 1.1rem 1.25rem; } +.login-error { margin-bottom: 0.85rem; } +.field-error { font-size: 0.78rem; margin-top: 0.2rem; } diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css new file mode 100644 index 0000000..53fb793 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css @@ -0,0 +1,379 @@ +/* ============================================================================ + Technical-Light design system — portable theme layer + ---------------------------------------------------------------------------- + A refined technical-light aesthetic: warm-neutral paper, hairline rules, + IBM Plex type, monospace tabular numerics, status carried by colour. Built + to layer over Bootstrap 5 via --bs-* overrides, but every rule below works + standalone — Bootstrap is optional. + + HOW TO ADOPT + 1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the + @font-face url() paths below to wherever you serve them. + 2. Include this file once, globally. Add view-specific rules in a separate + stylesheet — never edit the token block per-view. + 3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.* + helpers; do not hand-pick hex values in feature CSS. + ========================================================================= */ + +/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ─────────────────── + Adjust these url()s to your asset route. If you cannot vendor the fonts the + --sans / --mono fallback stacks below degrade gracefully to system fonts. */ +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 400; font-display: swap; + src: url('../fonts/ibm-plex-sans-400.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; font-weight: 600; font-display: swap; + src: url('../fonts/ibm-plex-sans-600.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; font-weight: 500; font-display: swap; + src: url('../fonts/ibm-plex-mono-500.woff2') format('woff2'); +} + +/* ── Design tokens ─────────────────────────────────────────────────────────── + The single source of truth. Re-theme by editing only this block. */ +:root { + /* Surfaces & ink */ + --paper: #f4f4f1; /* page background — warm off-white, never pure */ + --card: #ffffff; /* raised surfaces: cards, bars, table heads */ + --ink: #1b1d21; /* primary text */ + --ink-soft: #5a6066; /* secondary text, labels */ + --ink-faint: #8b9097; /* tertiary text, captions, units */ + --rule: #e4e4df; /* hairline borders / row dividers */ + --rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */ + + /* Accent */ + --accent: #2f5fd0; /* links, sort arrows, primary actions */ + --accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */ + + /* Status — foreground */ + --ok: #2f9e44; + --warn: #e8920c; + --bad: #e03131; + --idle: #868e96; + + /* Status — tinted backgrounds (pair with the matching foreground) */ + --ok-bg: #e9f6ec; + --warn-bg: #fdf1dd; + --bad-bg: #fceaea; + --idle-bg: #eef0f2; + + /* Type stacks — Plex first, graceful system fallback */ + --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + --sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + + /* Bootstrap 5 overrides — harmless if Bootstrap is absent */ + --bs-body-bg: var(--paper); + --bs-body-color: var(--ink); + --bs-body-font-family: var(--sans); + --bs-body-font-size: 0.9rem; + --bs-primary: var(--accent); + --bs-border-color: var(--rule); + --bs-emphasis-color: var(--ink); +} + +/* ── Base ──────────────────────────────────────────────────────────────────── + The faint top-right radial is the one deliberate flourish — a soft sheen, + not a gradient wash. Keep it subtle. */ +body { + background: + radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%), + var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 0.9rem; + -webkit-font-smoothing: antialiased; +} + +/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */ +.numeric, +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-deep); text-decoration: underline; } + +/* ── App chrome: top bar ───────────────────────────────────────────────────── + One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta + text and any status pill pushed hard right. */ +.app-bar { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.85rem 1.25rem; + background: var(--card); + border-bottom: 1px solid var(--rule-strong); +} +.app-bar .brand { + font-weight: 600; + font-size: 1.05rem; + letter-spacing: 0.02em; +} +.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */ +.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; } +.app-bar .spacer { flex: 1; } /* pushes meta/pill right */ +.app-bar .meta { + font-family: var(--mono); + font-size: 0.78rem; + color: var(--ink-soft); +} + +/* ── Connection / liveness pill ────────────────────────────────────────────── + A rounded pill with a dot, driven entirely by data-state. Use for any + live-link health indicator (websocket, SSE, polling). */ +.conn-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--rule-strong); + color: var(--ink-soft); + background: var(--card); +} +.conn-pill .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--idle); +} +.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); } +.conn-pill[data-state="connected"] .dot { background: var(--ok); } +.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); } +.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); } +.conn-pill[data-state="disconnected"] .dot { background: var(--bad); } + +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } + +/* ── Status text helpers ───────────────────────────────────────────────────── + Recolour a value in place — counts, ratios, error totals. */ +.s-ok { color: var(--ok); } +.s-warn { color: var(--warn); } +.s-bad { color: var(--bad); } +.s-idle { color: var(--idle); } + +/* ── State chip ────────────────────────────────────────────────────────────── + Compact rectangular badge for an enumerated state (bound/recovering/…). + Squarer than the pill; use the pill for liveness, the chip for state. */ +.chip { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 4px; + border: 1px solid transparent; +} +.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; } +.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; } +.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; } +.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); } + +/* ── Panel — the base raised surface ───────────────────────────────────────── + A white card with a hairline border and 8px radius. .panel-head is the + uppercase eyebrow label that sits on top. */ +.panel { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; +} +.panel-head { + font-size: 0.74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} + +/* ── Page wrapper ──────────────────────────────────────────────────────────── + Centred, capped width, even gutter. */ +.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; } + +/* ── Reveal-on-paint ───────────────────────────────────────────────────────── + Add .rise to top-level sections; stagger with inline animation-delay + (.02s, .08s, .14s …) so panels settle in sequence, not all at once. */ +@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.rise { animation: rise 0.4s ease both; } + +/* ════════════════════════════════════════════════════════════════════════════ + COMPONENT LIBRARY + Generic, reusable pieces. View-specific layout belongs in a separate sheet. + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── KPI / aggregate cards ─────────────────────────────────────────────────── + A responsive strip of headline numbers. .agg-card.alert / .caution tint the + whole card when a watched metric goes non-zero. */ +.agg-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.75rem; + margin-bottom: 1rem; +} +@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } } + +.agg-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + padding: 0.7rem 0.9rem; +} +.agg-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); +} +.agg-value { + margin-top: 0.25rem; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + display: flex; + align-items: baseline; + gap: 0.35rem; +} +.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */ + font-size: 0.85rem; + font-weight: 400; + color: var(--ink-faint); +} +.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); } +.agg-card.alert .agg-value { color: var(--bad); } +.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); } +.agg-card.caution .agg-value { color: #b56a00; } + +/* ── Metric card + key/value rows ──────────────────────────────────────────── + A .panel-head over a stack of .kv rows: label left, monospace value right. + Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 0.85rem; + margin-bottom: 1rem; +} +.metric-card { + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + overflow: hidden; +} +.metric-card .panel-head { margin: 0; } + +.kv { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.32rem 0.9rem; + font-size: 0.85rem; +} +.kv:nth-child(even) { background: #fbfbf9; } +.kv .k { color: var(--ink-soft); } +.kv .v { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +.kv .v.warn { color: var(--warn); } +.kv .v.bad { color: var(--bad); } +.kv .v.ok { color: var(--ok); } + +/* ── Toolbar ───────────────────────────────────────────────────────────────── + Filter/search row that sits inside a .panel above a table. */ +.toolbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--rule); +} +.toolbar .spacer { flex: 1; } +.tb-search { max-width: 280px; } +.tb-state { max-width: 150px; } +.tb-check { + display: flex; align-items: center; gap: 0.35rem; + font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; + user-select: none; +} +.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); } + +/* ── Data table ────────────────────────────────────────────────────────────── + Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric + columns get .num (right-aligned, monospace). Rows are clickable by default — + drop the cursor/hover rules if yours are not. */ +.table-wrap { overflow-x: auto; } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.data-table th, +.data-table td { + padding: 0.45rem 0.8rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--rule); +} +.data-table th { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + background: #fbfbf9; + position: sticky; + top: 0; +} +.data-table th.num, +.data-table td.num { text-align: right; font-family: var(--mono); } + +.data-table th.sortable { cursor: pointer; user-select: none; } +.data-table th.sortable:hover { color: var(--ink); } +.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); } +.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); } + +.data-table tbody tr { cursor: pointer; transition: background 0.08s; } +.data-table tbody tr:hover { background: #f3f6fd; } +.data-table tbody tr:last-child td { border-bottom: none; } + +.empty-row { + text-align: center !important; + color: var(--ink-faint); + padding: 1.6rem !important; + font-style: italic; +} + +/* ── Direction / category tag ──────────────────────────────────────────────── + Tiny inline tag for a per-row category (e.g. read vs write). */ +.dir-tag { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.4rem; + border-radius: 3px; +} +.dir-read { color: var(--accent-deep); background: #e7ecfb; } +.dir-write { color: #8a5a00; background: var(--warn-bg); } + +/* ── Inline notice ─────────────────────────────────────────────────────────── + A .panel with a warning tint — for "this thing is gone / degraded" banners. */ +.notice { + padding: 0.85rem 1.1rem; + margin-bottom: 1rem; + color: #b56a00; + background: var(--warn-bg); + border-color: #efd6a6; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000..99c2610 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 differ diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 new file mode 100644 index 0000000..93bcd64 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 differ diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 new file mode 100644 index 0000000..0ac91d6 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 differ diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs new file mode 100644 index 0000000..2bc89ec --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs @@ -0,0 +1,35 @@ +using System.IO; + +namespace ZB.MOM.WW.Theme.Tests; + +public class StaticAssetsTests +{ + // wwwroot is copied next to the test assembly via the RCL static-web-asset pipeline, + // but the simplest stable check is against the source tree relative to the test binary. + private static string Wwwroot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, + "..", "..", "..", "..", "..", "src", "ZB.MOM.WW.Theme", "wwwroot")); + + [Fact] + public void ThemeCss_exists_and_defines_accent_token() + { + var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css")); + Assert.Contains("--accent:", css); + Assert.Contains("--ok:", css); + } + + [Fact] + public void ThemeCss_uses_corrected_relative_font_path() + { + var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css")); + Assert.Contains("url('../fonts/ibm-plex-sans-400.woff2')", css); + Assert.DoesNotContain("url('fonts/ibm-plex", css); // the latent 404 path is gone + } + + [Theory] + [InlineData("ibm-plex-sans-400.woff2")] + [InlineData("ibm-plex-sans-600.woff2")] + [InlineData("ibm-plex-mono-500.woff2")] + public void Fonts_are_vendored(string file) => + Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file))); +}