From c0adbcbc8ad4b0ae47533f747d1a0eb56d253701 Mon Sep 17 00:00:00 2001 From: chick Date: Tue, 8 Sep 2026 01:00:22 +0800 Subject: [PATCH] style(web): unify accessible control system --- apps/web/src/styles.css | 1005 +++++++++++++---- .../web/src/ui/button-contrast.audit.test.tsx | 100 ++ apps/web/src/ui/button-system.test.ts | 268 +++++ apps/web/src/ui/test-css.ts | 40 + 4 files changed, 1176 insertions(+), 237 deletions(-) create mode 100644 apps/web/src/ui/button-contrast.audit.test.tsx create mode 100644 apps/web/src/ui/button-system.test.ts create mode 100644 apps/web/src/ui/test-css.ts diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fb65cf8..5a10ca7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -50,6 +50,36 @@ --shadow-sm: 0 1px 2px rgba(20, 35, 55, 0.04); --shadow: 0 8px 25px rgba(20, 35, 55, 0.07); --shadow-lg: 0 18px 45px rgba(20, 35, 55, 0.12); + /* Button system: one compact scale shared by every action control. */ + --button-height: 2.375rem; + --button-height-compact: 2.125rem; + --button-height-large: 2.625rem; + --icon-button-size: 2.5rem; + --button-radius: 10px; + --button-radius-compact: 8px; + --button-focus-ring: #0f6e66; + --button-primary-bg: #11776e; + --button-primary-hover: #0f6e66; + --button-primary-active: #0d5f58; + --button-primary-text: #fffdf7; + --button-secondary-bg: #fffdf7; + --button-secondary-text: #584a38; + --button-secondary-border: #bdb29d; + --button-secondary-hover-bg: #ecfaf6; + --button-secondary-hover-text: #0f6e66; + --button-secondary-hover-border: #72cbbd; + --button-danger-bg: #b64d48; + --button-danger-hover: #a83f3a; + --button-danger-active: #8f3531; + --button-danger-text: #fffdf7; + --button-disabled-bg: #ece6da; + --button-disabled-text: #6f6350; + --button-disabled-border: #d9d1bf; + --field-height: 2.375rem; + --field-radius: 10px; + --field-border: var(--line-strong); + --field-bg: #ffffff; + --field-focus-border: var(--button-focus-ring); font-synthesis: none; text-rendering: optimizeLegibility; } @@ -86,17 +116,25 @@ a { outline-offset: 2px; } :focus-visible { - outline: 3px solid rgba(49, 91, 234, 0.3); + outline: 3px solid var(--button-focus-ring); outline-offset: 2px; } button, input, select, textarea { - min-height: 2.6rem; - border: 1px solid var(--line-strong); - border-radius: 9px; - background: #fff; + border: 1px solid var(--field-border); + border-radius: var(--field-radius); + background: var(--field-bg); +} +button { + min-height: var(--button-height); + border-radius: var(--button-radius); +} +input, +select, +textarea { + min-height: var(--field-height); } input, select, @@ -106,45 +144,59 @@ textarea { input:focus, select:focus, textarea:focus { - border-color: #7895f3; - box-shadow: 0 0 0 3px rgba(49, 91, 234, 0.1); + border-color: var(--field-focus-border); + box-shadow: none; outline: none; } button { - padding: 0.5rem 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.45rem 0.85rem; + border-color: var(--button-secondary-border); + color: var(--button-secondary-text); + background: var(--button-secondary-bg); cursor: pointer; - font-weight: 700; - box-shadow: var(--shadow-sm); + font-weight: 750; + box-shadow: none; transition: border-color 150ms ease, color 150ms ease, background 150ms ease, + box-shadow 150ms ease, transform 150ms ease; } button:hover:not(:disabled) { - color: var(--primary); - border-color: #9caff0; - background: #f7f9ff; + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); } button:active:not(:disabled) { transform: translateY(1px); } button:disabled { cursor: not-allowed; - opacity: 0.5; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + box-shadow: none; + opacity: 1; } button[type='submit'], -.primary-action { - color: #fff; - border-color: var(--primary); - background: var(--primary); - box-shadow: 0 5px 12px rgba(49, 91, 234, 0.18); +button.primary-action, +a.primary-action { + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + background: var(--button-primary-bg); + box-shadow: 0 3px 0 var(--button-primary-active); } button[type='submit']:hover:not(:disabled), -.primary-action:hover { - color: #fff; - border-color: var(--primary-hover); - background: var(--primary-hover); +button.primary-action:hover:not(:disabled), +a.primary-action:hover { + color: var(--button-primary-text); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); } h1, h2, @@ -192,7 +244,7 @@ code { left: 1rem; z-index: 100; padding: 0.7rem 1rem; - color: #fff; + color: var(--button-primary-text); background: var(--primary); border-radius: 8px; } @@ -398,12 +450,13 @@ main > section > header p, flex-wrap: wrap; } .primary-action { - min-height: 2.6rem; + min-height: var(--button-height); display: inline-flex; align-items: center; - padding: 0.5rem 0.9rem; - border-radius: 9px; - font-weight: 800; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border-radius: var(--button-radius); + font-weight: 750; text-decoration: none; } .selection-summary { @@ -1115,7 +1168,7 @@ button.fleet-cross-health-item { } .fleet-card-admin-actions button { flex: 1 1 7rem; - min-height: 2.25rem; + min-height: var(--button-height-compact); font-size: 0.75rem; } .fleet-card-section + .pagination { @@ -1126,7 +1179,7 @@ button.fleet-cross-health-item { margin-top: 0.9rem; } .pagination button { - min-height: 2.3rem; + min-height: var(--button-height-compact); } /* States and forms */ @@ -2081,7 +2134,7 @@ dd { align-items: flex-start; margin: 0.55rem 0 0; padding: 0.5rem 0.6rem; - border: 1px solid rgba(217, 87, 87, 0.2); + border: 1px solid rgba(168, 64, 59, 0.2); border-radius: 10px; color: #98393a; background: rgba(253, 233, 231, 0.72); @@ -2621,10 +2674,15 @@ main ul[aria-label='已配置实例'] > li { --workbench-border: #d9d1bf; --workbench-border-strong: #bdb29d; --workbench-text: #584a38; - --workbench-muted: #7f725f; - --workbench-mint: #178f84; + --workbench-muted: #6f6350; + --workbench-mint: #0f6e66; --workbench-mint-soft: #dff3e8; --workbench-yellow: #f7cd67; + --workbench-warning-text: #825f0c; + --workbench-warning-soft: #fdf1d2; + --workbench-mint-border: #b3d7c6; + --workbench-warning-border: #e5cd96; + --workbench-danger-border: #e0b7b2; --workbench-danger: #b64d48; --workbench-danger-soft: #f9e5e2; font-family: @@ -2761,28 +2819,39 @@ main { align-items: center; } -.primary-action { - min-height: 2.5rem; - padding: 0 0.95rem; - border: 1px solid #11776e; - border-radius: 10px; - color: #fff; - background: var(--workbench-mint); - box-shadow: none; +button.primary-action, +a.primary-action { + min-height: var(--button-height); + padding: 0.45rem 0.95rem; + border: 1px solid var(--button-primary-bg); + border-radius: var(--button-radius); + color: var(--button-primary-text); + background: var(--button-primary-bg); + box-shadow: 0 3px 0 var(--button-primary-active); font: inherit; font-weight: 750; letter-spacing: 0; cursor: pointer; transition: + border-color 150ms ease, + color 150ms ease, background-color 150ms ease, + box-shadow 150ms ease, transform 100ms ease; } -.primary-action:hover { - background: #11776e; +button.primary-action:hover:not(:disabled), +a.primary-action:hover { + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + transform: translateY(-1px); } -.primary-action:active { - transform: scale(0.98); +button.primary-action:active:not(:disabled), +a.primary-action:active { + border-color: var(--button-primary-active); + background: var(--button-primary-active); + box-shadow: 0 1px 0 var(--button-primary-active); + transform: translateY(1px); } .fleet-workspace, @@ -3042,12 +3111,13 @@ main { display: grid; justify-items: end; } -.row-action-trigger { - width: 2.75rem; - min-height: 2.75rem; +button.row-action-trigger { + width: var(--icon-button-size); + min-height: var(--icon-button-size); display: grid; place-items: center; padding: 0; + border-radius: var(--button-radius); } .row-action-trigger svg { width: 1rem; @@ -3117,7 +3187,7 @@ main { transform: translateX(0.92rem); } .compact-switch input:focus-visible + span { - outline: 3px solid rgba(23, 143, 132, 0.24); + outline: 3px solid var(--button-focus-ring); outline-offset: 2px; } @@ -3176,17 +3246,28 @@ main { margin-bottom: 0.75rem; font-size: 0.82rem; } -.icon-button { - width: 2.75rem; - height: 2.75rem; - border: 0; - border-radius: 50%; +button.icon-button, +a.icon-button { + width: var(--icon-button-size); + height: var(--icon-button-size); + min-height: var(--icon-button-size); + padding: 0; + border: 1px solid transparent; + border-radius: var(--button-radius); color: var(--workbench-muted); background: var(--workbench-surface-muted); - font-size: 1.35rem; + box-shadow: none; + font-size: 1.2rem; cursor: pointer; } +button.icon-button:hover:not(:disabled), +a.icon-button:hover { + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); +} + .icon-button svg { width: 1.1rem; height: 1.1rem; @@ -3210,12 +3291,12 @@ main { .schedule-drawer select, .schedule-drawer textarea { width: 100%; - min-height: 2.75rem; + min-height: var(--button-height-large); padding: 0.58rem 0.72rem; border: 1px solid var(--workbench-border-strong); - border-radius: 10px; + border-radius: var(--field-radius); color: var(--workbench-text); - background: #fff; + background: var(--field-bg); font: inherit; letter-spacing: 0; } @@ -3226,8 +3307,8 @@ main { .schedule-drawer input:focus, .schedule-drawer select:focus, .schedule-drawer textarea:focus { - border-color: var(--workbench-mint); - outline: 3px solid rgba(23, 143, 132, 0.16); + border-color: var(--field-focus-border); + outline: 3px solid var(--button-focus-ring); } .field-block { @@ -3250,7 +3331,7 @@ main { gap: 0.45rem; } .inline-field button { - min-height: 2.75rem; + min-height: var(--button-height-compact); border: 1px solid var(--workbench-border-strong); border-radius: 10px; color: var(--workbench-text); @@ -3273,10 +3354,10 @@ main { } .segmented-control button { flex: 1 1 0; - min-height: 2.2rem; - padding: 0 0.5rem; + min-height: var(--button-height-compact); + padding: 0.3rem 0.5rem; border: 1px solid transparent; - border-radius: 9px; + border-radius: var(--button-radius-compact); color: var(--workbench-muted); background: transparent; font: inherit; @@ -3290,7 +3371,7 @@ main { background: #fff; } .segmented-control button:focus-visible { - outline: 3px solid rgba(23, 143, 132, 0.16); + outline: 3px solid var(--button-focus-ring); } .weekday-picker { display: flex; @@ -3490,8 +3571,8 @@ main { overflow-wrap: anywhere; } .organization-list .icon-button { - width: 2.1rem; - height: 2.1rem; + width: var(--icon-button-size); + height: var(--icon-button-size); flex: none; } .organization-list .icon-button svg { @@ -3499,10 +3580,13 @@ main { height: 0.9rem; } .organization-list .icon-button.danger { - color: #b04141; + color: var(--button-danger-bg); } .organization-list .icon-button:disabled { - opacity: 0.45; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; cursor: not-allowed; } .organization-empty { @@ -3523,10 +3607,13 @@ main { border-bottom: 1px solid var(--workbench-border); } .identity-toolbar button:first-child { - min-height: 2.1rem; - padding: 0 0.7rem; + min-height: var(--button-height-compact); + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.7rem; border: 1px solid var(--workbench-border); - border-radius: 999px; + border-radius: var(--button-radius-compact); color: var(--workbench-muted); background: #fff; font: inherit; @@ -3653,10 +3740,14 @@ main { justify-content: flex-end; } .identity-actions button { - min-height: 2.1rem; - padding: 0 0.8rem; + min-height: var(--button-height-compact); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.3rem 0.8rem; border: 1px solid var(--workbench-border-strong); - border-radius: 999px; + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: #fff; font: inherit; @@ -3665,13 +3756,25 @@ main { cursor: pointer; } .identity-actions button:disabled { - opacity: 0.45; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; cursor: not-allowed; } -.identity-actions .primary-action { - color: #fff; - border-color: var(--workbench-mint); - background: var(--workbench-mint); +.identity-actions button.primary-action { + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + background: var(--button-primary-bg); +} +.identity-actions button.primary-action:hover:not(:disabled) { + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); +} +.identity-actions button.primary-action:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); } .identity-hint { margin: 0; @@ -3685,7 +3788,7 @@ main { gap: 0.25rem; margin: 0; padding: 0.45rem 0.55rem; - border: 1px solid rgba(217, 87, 87, 0.16); + border: 1px solid rgba(168, 64, 59, 0.16); border-radius: 12px; background: rgba(253, 233, 231, 0.72); font-size: 0.74rem; @@ -4046,23 +4149,24 @@ main { color: #725d42; background: #e9f4df; --ink: #725d42; - --muted: #8f806b; - --faint: #aa9d89; + --muted: #756a58; + --faint: #7d6f5c; --line: #ded4bf; --line-strong: #c9bda5; --surface: #fffdf5; --surface-soft: #f7f3df; --canvas: #e9f4df; - --primary: #19b8aa; - --primary-hover: #109d92; + --primary: #0f6e66; + --primary-hover: #0d6259; --primary-soft: #e2f7f2; - --cyan: #19b8aa; - --success: #65a82d; + --cyan: #0f6e66; + --success: #4a7d1f; --success-soft: #edf7df; --warning: #b58b0a; --warning-soft: #fff5cf; - --danger: #d95757; + --danger: #a8403b; --danger-soft: #fde9e7; + --field-bg: #fffdf7; --radius: 18px; --radius-sm: 14px; --shadow-sm: 0 2px 0 rgba(114, 93, 66, 0.08); @@ -4084,17 +4188,17 @@ body { } a { - color: #138f85; + color: #0f6e66; } :focus-visible { - outline-color: #19b8aa; + outline-color: var(--button-focus-ring); } input:focus-visible, select:focus-visible, textarea:focus-visible { - outline: 3px solid rgba(245, 195, 28, 0.38); + outline: 3px solid var(--button-focus-ring); border-color: #e5b70d; box-shadow: none; } @@ -4104,34 +4208,63 @@ input:not([class*='animal-']), select, textarea { border-color: var(--line-strong); - border-radius: 16px; + border-radius: var(--button-radius); background: #fffdf7; } button:not([class*='animal-']):hover:not(:disabled) { - color: #117f76; - border-color: #72cbbd; - background: #ecfaf6; + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); } +/* + * The primary action used to be declared with a bare `.primary-action` selector here, which + * lost the cascade to `button:not([class*='animal-'])` above. That left white label text on the + * cream secondary surface, so "立即投递" and "刷新" looked like empty pills. Every primary-action + * rule now carries the element qualifier so it always outranks the generic button surface. + */ button[type='submit']:not([class*='animal-']), -.primary-action { - color: #fff; - border-color: #19b8aa; - border-radius: 999px; - background: #19b8aa; - box-shadow: 0 5px 0 #0d887f; +button.primary-action, +a.primary-action { + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + border-radius: var(--button-radius); + background: var(--button-primary-bg); + box-shadow: 0 3px 0 var(--button-primary-active); } button[type='submit']:not([class*='animal-']):hover:not(:disabled), -.primary-action:hover { - color: #fff; - border-color: #31c9bb; - background: #31c9bb; - box-shadow: 0 6px 0 #0d887f; +button.primary-action:hover:not(:disabled), +a.primary-action:hover { + color: var(--button-primary-text); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + box-shadow: 0 4px 0 var(--button-primary-active); transform: translateY(-1px); } +button[type='submit']:not([class*='animal-']):active:not(:disabled), +button.primary-action:active:not(:disabled), +a.primary-action:active { + color: var(--button-primary-text); + border-color: var(--button-primary-active); + background: var(--button-primary-active); + box-shadow: 0 1px 0 var(--button-primary-active); + transform: translateY(1px); +} + +button[type='submit']:not([class*='animal-']):disabled, +button.primary-action:disabled, +a.primary-action[aria-disabled='true'] { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + box-shadow: none; + transform: none; + opacity: 1; +} + h1, h2, h3 { @@ -4229,7 +4362,7 @@ h1 { height: 0.48rem; margin-right: 0.38rem; border-radius: 50%; - background: #19b8aa; + background: #0f6e66; } .connection-status::before { @@ -4252,7 +4385,7 @@ h1 { .connection-status span[data-state='reconnecting']::before, .connection-status span[data-state='closed']::before { - background: #d95757; + background: #a8403b; } .app-layout { @@ -4366,7 +4499,7 @@ main > section > header h1, .fleet-search-glyph { position: static; - color: #8f806b; + color: #6f6350; transform: none; } @@ -4396,7 +4529,7 @@ main > section > header h1, .fleet-active-chip, .fleet-clear-filters { - border-radius: 999px; + border-radius: var(--button-radius-compact); } .fleet-active-chip { @@ -4455,11 +4588,11 @@ main > section > header h1, .fleet-card::before { width: 0.45rem; border-radius: 20px 0 0 20px; - background: #19b8aa; + background: #0f6e66; } .fleet-card[data-status='offline']::before { - background: #d95757; + background: #a8403b; } .fleet-card[data-status='auth']::before { @@ -4477,8 +4610,8 @@ main > section > header h1, } .fleet-card.is-selected { - border-color: #19b8aa; - box-shadow: 0 0 0 3px rgba(25, 184, 170, 0.14); + border-color: #0f6e66; + box-shadow: 0 0 0 3px rgba(15, 110, 102, 0.14); } .fleet-card-glow { @@ -4513,7 +4646,7 @@ main > section > header h1, --apple-surface-strong: rgba(255, 255, 255, 0.9); --apple-hairline: rgba(101, 83, 59, 0.14); --apple-shadow: 0 1px 2px rgba(74, 61, 43, 0.05), 0 10px 30px rgba(74, 61, 43, 0.08); - --apple-shadow-selected: 0 0 0 3px rgba(25, 184, 170, 0.16), 0 14px 34px rgba(37, 115, 103, 0.12); + --apple-shadow-selected: 0 0 0 3px rgba(15, 110, 102, 0.16), 0 14px 34px rgba(37, 115, 103, 0.12); font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Noto Sans SC', 'PingFang SC', system-ui, sans-serif; @@ -4633,7 +4766,7 @@ main > section > header h1, } .fleet-card.is-selected { - border-color: rgba(25, 184, 170, 0.7); + border-color: rgba(15, 110, 102, 0.7); background: rgba(247, 255, 252, 0.9); box-shadow: var(--apple-shadow-selected); } @@ -4691,9 +4824,9 @@ main > section > header h1, } .fleet-card-select input:checked { - border-color: #19a99d; - background: #19b8aa; - box-shadow: 0 2px 7px rgba(25, 169, 157, 0.24); + border-color: #0f6e66; + background: #0f6e66; + box-shadow: 0 2px 7px rgba(15, 110, 102, 0.24); } .fleet-card-select input:checked::before { @@ -4705,7 +4838,7 @@ main > section > header h1, } .fleet-card-select input:focus-visible { - outline: 3px solid rgba(25, 184, 170, 0.22); + outline: 3px solid var(--button-focus-ring); outline-offset: 2px; } @@ -4763,12 +4896,12 @@ main > section > header h1, .fleet-card-description { padding: 0; border: 0; - color: #81745f; + color: #6f6350; line-height: 1.5; } .fleet-card-anomalies { - border: 1px solid rgba(217, 87, 87, 0.16); + border: 1px solid rgba(168, 64, 59, 0.18); border-radius: 12px; background: rgba(253, 233, 231, 0.72); } @@ -4796,7 +4929,7 @@ main > section > header h1, } .fleet-card-metrics dt { - color: #8d816e; + color: #6f6350; font-size: 0.66rem; font-weight: 600; } @@ -4815,7 +4948,7 @@ main > section > header h1, } .fleet-card-sms h3 { - color: #766a58; + color: #6f6350; font-weight: 650; } @@ -5305,7 +5438,7 @@ svg { .fleet-search-glyph { position: static; - color: #8f806b; + color: #6f6350; transform: none; } @@ -5823,30 +5956,32 @@ svg { flex: 0 0 auto; } -.fleet-card-menu-trigger, -.fleet-card-menu-trigger:hover:not(:disabled), -.fleet-card-menu-trigger:active:not(:disabled) { +button.fleet-card-menu-trigger, +button.fleet-card-menu-trigger:hover:not(:disabled), +button.fleet-card-menu-trigger:active:not(:disabled) { display: grid; - width: 2.75rem; - height: 2.75rem; - min-height: 2.75rem; + width: var(--icon-button-size); + height: var(--icon-button-size); + min-height: var(--icon-button-size); place-items: center; padding: 0; - border: 0; - border-radius: 50%; + border: 1px solid transparent; + border-radius: var(--button-radius); color: var(--workbench-muted); + border-color: transparent; background: transparent; box-shadow: none; transform: none; } -.fleet-card-menu-trigger:hover:not(:disabled) { - color: var(--workbench-text); - background: var(--workbench-surface-muted); +button.fleet-card-menu-trigger:hover:not(:disabled) { + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); } -.fleet-card-menu-trigger:focus-visible { - outline: 2px solid var(--workbench-mint); +button.fleet-card-menu-trigger:focus-visible { + outline: 3px solid var(--button-focus-ring); outline-offset: 2px; } @@ -5866,7 +6001,7 @@ svg { padding: 0.35rem; border: 1px solid var(--workbench-border); border-radius: 8px; - background: #fffef9; + background: var(--workbench-surface); box-shadow: 0 10px 24px rgba(68, 55, 38, 0.14); } @@ -5876,13 +6011,13 @@ svg { .fleet-card-menu-panel button:active:not(:disabled) { display: flex; width: 100%; - min-height: 2.75rem; + min-height: var(--button-height-compact); align-items: center; justify-content: flex-start; gap: 0.55rem; - padding: 0.55rem 0.7rem; + padding: 0.4rem 0.65rem; border: 0; - border-radius: 6px; + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: transparent; box-shadow: none; @@ -5900,7 +6035,7 @@ svg { .fleet-card-menu-panel button:focus-visible, .fleet-card-menu-panel .fleet-card-menu-item:focus-visible { - outline: 2px solid var(--workbench-mint); + outline: 3px solid var(--button-focus-ring); outline-offset: -2px; } @@ -5949,13 +6084,13 @@ svg { } .fleet-card-access[data-kind='local'] { - border-color: color-mix(in srgb, var(--workbench-mint) 45%, var(--workbench-border)); + border-color: var(--workbench-mint-border); color: var(--workbench-mint); } .fleet-card-access[data-kind='wan'] { - border-color: color-mix(in srgb, var(--workbench-yellow) 50%, var(--workbench-border)); - color: var(--workbench-yellow); + border-color: var(--workbench-warning-border); + color: var(--workbench-warning-text); } .fleet-card-heartbeat { @@ -5981,7 +6116,7 @@ svg { .fleet-card-heartbeat[data-stale='true'] > svg, .fleet-card-heartbeat[data-stale='true'] time { - color: var(--workbench-yellow); + color: var(--workbench-warning-text); } .fleet-card-origin-link, @@ -6375,17 +6510,24 @@ svg { } .automation-workspace button.primary-action { - color: #fff; - border-color: #11776e; - background: var(--workbench-mint); - box-shadow: 0 3px 0 #0d756d; + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + background: var(--button-primary-bg); + box-shadow: 0 3px 0 var(--button-primary-active); } .automation-workspace button.primary-action:hover:not(:disabled) { - color: #fff; - border-color: #0f6e66; - background: #11776e; - box-shadow: 0 3px 0 #0d756d; + color: var(--button-primary-text); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + box-shadow: 0 4px 0 var(--button-primary-active); +} + +.automation-workspace button.primary-action:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + box-shadow: none; } .automation-workspace .workbench-tabs button, @@ -6503,12 +6645,12 @@ svg { .fleet-phone-privacy-toggle[aria-pressed='true'] { color: var(--workbench-mint); - border-color: rgba(17, 119, 110, 0.36); - background: #f1faf6; + border-color: var(--workbench-mint-border); + background: var(--workbench-mint-soft); } .fleet-phone-privacy-toggle:focus-visible { - outline: 2px solid var(--workbench-mint); + outline: 3px solid var(--button-focus-ring); outline-offset: 2px; } @@ -6642,8 +6784,8 @@ svg { width: 100%; min-height: 2.3rem; padding: 0.45rem 0.55rem; - border: 1px solid var(--workbench-border); - border-radius: 9px; + border: 1px solid var(--workbench-border-strong); + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: var(--workbench-surface-muted); font: inherit; @@ -6664,13 +6806,13 @@ svg { font-weight: 800; } .fleet-messages-device-item[data-active] { - border-color: var(--workbench-mint); + border-color: var(--field-focus-border); background: var(--workbench-mint-soft); color: var(--workbench-mint); } .fleet-messages-device-item[data-unavailable] { color: var(--workbench-danger); - border-color: rgba(183, 77, 72, 0.25); + border-color: var(--workbench-danger-border); background: var(--workbench-danger-soft); cursor: default; opacity: 0.75; @@ -6813,8 +6955,8 @@ svg { min-width: 0; box-sizing: border-box; padding: 0.55rem 0.6rem; - border: 1px solid var(--workbench-border); - border-radius: 9px; + border: 1px solid var(--workbench-border-strong); + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: var(--workbench-surface-muted); font: inherit; @@ -6823,8 +6965,8 @@ svg { .fleet-messages-form input:focus, .fleet-messages-form select:focus, .fleet-messages-form textarea:focus { - border-color: var(--workbench-mint); - outline: 2px solid color-mix(in srgb, var(--workbench-mint) 22%, transparent); + border-color: var(--field-focus-border); + outline: 3px solid var(--button-focus-ring); outline-offset: 1px; } .fleet-messages-form textarea { @@ -6846,10 +6988,10 @@ svg { align-items: center; justify-content: center; gap: 0.4rem; - min-height: 2.1rem; - padding: 0.4rem 0.7rem; - border: 1px solid rgba(17, 119, 110, 0.4); - border-radius: 9px; + min-height: var(--button-height-compact); + padding: 0.3rem 0.7rem; + border: 1px solid var(--button-primary-bg); + border-radius: var(--button-radius-compact); color: var(--workbench-surface); background: var(--workbench-mint); font: inherit; @@ -6861,7 +7003,10 @@ svg { .fleet-messages-more:disabled, .fleet-messages-search button:disabled { cursor: default; - opacity: 0.55; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; } .fleet-messages-form footer button svg, .fleet-messages-more svg, @@ -6872,22 +7017,22 @@ svg { .fleet-messages-result { margin: 0.65rem 0 0; padding: 0.55rem 0.65rem; - border: 1px solid rgba(17, 119, 110, 0.3); - border-radius: 9px; + border: 1px solid var(--workbench-mint-border); + border-radius: var(--button-radius-compact); color: var(--workbench-mint); background: var(--workbench-mint-soft); font-size: 0.72rem; font-weight: 750; } .fleet-messages-result.is-error { - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); color: var(--workbench-danger); background: var(--workbench-danger-soft); } .fleet-messages-result.is-partial { - border-color: rgba(170, 120, 35, 0.3); - color: #8a6722; - background: #fff8e8; + border-color: var(--workbench-warning-border); + color: var(--workbench-warning-text); + background: var(--workbench-warning-soft); } .fleet-messages-composer { padding-top: 0.7rem; @@ -6912,23 +7057,32 @@ svg { gap: 0.5rem; } .fleet-outbox-flush { - min-height: 2.1rem; - padding: 0.35rem 0.7rem; - border: 1px solid var(--workbench-border); - border-radius: 9px; - color: var(--workbench-text); - background: var(--workbench-surface-muted); + min-height: var(--button-height-compact); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.3rem 0.7rem; + border: 1px solid var(--button-primary-bg); + border-radius: var(--button-radius-compact); + color: var(--button-primary-text); + background: var(--button-primary-bg); font: inherit; font-size: 0.72rem; font-weight: 800; cursor: pointer; } .fleet-outbox-flush:hover:not(:disabled) { - border-color: var(--workbench-mint); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + color: var(--button-primary-text); } .fleet-outbox-flush:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; cursor: default; - opacity: 0.55; } .fleet-outbox-table { width: 100%; @@ -6991,22 +7145,22 @@ svg { .fleet-messages-search input { width: min(100%, 15rem); min-width: 0; - min-height: 2.1rem; + min-height: var(--button-height-compact); padding: 0.4rem 0.55rem; - border: 1px solid var(--workbench-border); - border-radius: 9px; + border: 1px solid var(--workbench-border-strong); + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: var(--workbench-surface-muted); font: inherit; font-size: 0.76rem; } .fleet-messages-search input:focus { - border-color: var(--workbench-mint); - outline: 2px solid color-mix(in srgb, var(--workbench-mint) 22%, transparent); + border-color: var(--field-focus-border); + outline: 3px solid var(--button-focus-ring); outline-offset: 1px; } .fleet-messages-search button { - min-width: 2.2rem; + min-width: var(--icon-button-size); padding: 0; } .fleet-messages-message-tools { @@ -7039,11 +7193,11 @@ svg { align-items: center; justify-content: center; gap: 0.4rem; - min-height: 2.1rem; + min-height: var(--button-height-compact); min-width: 8.2rem; padding: 0.4rem 0.65rem; - border: 1px solid rgba(183, 77, 72, 0.4); - border-radius: 9px; + border: 1px solid var(--button-danger-bg); + border-radius: var(--button-radius-compact); color: var(--workbench-danger); background: var(--workbench-danger-soft); font: inherit; @@ -7057,7 +7211,10 @@ svg { } .fleet-messages-delete:disabled { cursor: default; - opacity: 0.55; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; } .fleet-messages-list { display: grid; @@ -7143,14 +7300,14 @@ svg { .fleet-messages-message footer span[data-state='incoming'], .fleet-messages-message footer span[data-state='received'] { color: var(--workbench-mint); - border-color: rgba(17, 119, 110, 0.3); + border-color: var(--workbench-mint-border); background: var(--workbench-mint-soft); } .fleet-messages-message footer span[data-state='outgoing'], .fleet-messages-message footer span[data-state='sent'] { - color: #8a6722; - border-color: rgba(170, 120, 35, 0.3); - background: #fff8e8; + color: var(--workbench-warning-text); + border-color: var(--workbench-warning-border); + background: var(--workbench-warning-soft); } .fleet-messages-empty, .fleet-messages-loading-more { @@ -7250,26 +7407,29 @@ svg { } .fleet-notifications-retry-all { flex: 0 0 auto; - min-height: 2rem; + min-height: var(--button-height-compact); display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0.65rem; - border: 1px solid rgba(17, 119, 110, 0.3); - border-radius: 8px; + border: 1px solid var(--button-primary-bg); + border-radius: var(--button-radius-compact); color: var(--workbench-mint); background: var(--workbench-mint-soft); font-size: 0.68rem; font-weight: 800; } .fleet-notifications-retry-all:hover:not(:disabled) { - border-color: rgba(17, 119, 110, 0.5); - background: var(--workbench-mint); - color: #fff; + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + color: var(--button-primary-text); } .fleet-notifications-retry-all:disabled { cursor: default; - opacity: 0.55; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; } .fleet-notifications-retry-all svg { width: 0.82rem; @@ -7291,15 +7451,15 @@ svg { background: var(--workbench-surface-muted); } .fleet-notifications-counts div[data-state='ok'] { - border-color: rgba(17, 119, 110, 0.3); + border-color: var(--workbench-mint-border); background: var(--workbench-mint-soft); } .fleet-notifications-counts div[data-state='warn'] { - border-color: rgba(170, 120, 35, 0.32); - background: #fff8e8; + border-color: var(--workbench-warning-border); + background: var(--workbench-warning-soft); } .fleet-notifications-counts div[data-state='attention'] { - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); background: var(--workbench-danger-soft); } .fleet-notifications-counts dt { @@ -7357,7 +7517,7 @@ svg { gap: 0.4rem; padding: 0.45rem 0.85rem; border: 1px solid var(--workbench-border-strong); - border-radius: 9px; + border-radius: var(--button-radius-compact); color: var(--workbench-muted); background: var(--workbench-surface-muted); font: inherit; @@ -7419,9 +7579,10 @@ svg { } .fleet-log-filters select, .fleet-log-filters input { - min-height: 2rem; + min-height: var(--button-height-compact); padding: 0.25rem 0.5rem; - border-radius: 8px; + border: 1px solid var(--workbench-border-strong); + border-radius: var(--button-radius-compact); font-size: 0.68rem; } .fleet-log-filters select { @@ -7536,11 +7697,15 @@ svg { width: 7.5rem; margin-left: auto; padding: 0.25rem 0.5rem; - border-radius: 8px; + border: 1px solid var(--workbench-border-strong); + border-radius: var(--button-radius-compact); font-size: 0.68rem; } .fleet-log-retention-value:disabled { - opacity: 0.55; + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; } .fleet-log-retention-actions { display: flex; @@ -7615,14 +7780,14 @@ svg { gap: 0.3rem; } .fleet-notification-entry-button { - width: 1.9rem; - height: 1.9rem; + width: var(--icon-button-size); + height: var(--icon-button-size); display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 1px solid var(--workbench-border); - border-radius: 8px; + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: var(--workbench-surface); } @@ -7635,8 +7800,11 @@ svg { color: var(--workbench-danger); } .fleet-notification-entry-button:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; cursor: default; - opacity: 0.45; } .fleet-notification-entry-button svg { width: 0.82rem; @@ -7644,31 +7812,31 @@ svg { } .fleet-notification-entry[data-tone='ok'] > span { color: var(--workbench-mint); - border-color: rgba(17, 119, 110, 0.3); + border-color: var(--workbench-mint-border); background: var(--workbench-mint-soft); } .fleet-notification-entry[data-tone='warn'] > span { - color: #8a6722; - border-color: rgba(170, 120, 35, 0.32); - background: #fff8e8; + color: var(--workbench-warning-text); + border-color: var(--workbench-warning-border); + background: var(--workbench-warning-soft); } .fleet-notification-entry[data-tone='attention'] > span { color: var(--workbench-danger); - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); background: var(--workbench-danger-soft); } .fleet-notifications-result { margin: 0; padding: 0.55rem 0.6rem; - border: 1px solid rgba(17, 119, 110, 0.3); - border-radius: 9px; + border: 1px solid var(--workbench-mint-border); + border-radius: var(--button-radius-compact); color: var(--workbench-mint); background: var(--workbench-mint-soft); font-size: 0.7rem; line-height: 1.4; } .fleet-notifications-result.is-error { - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); color: var(--workbench-danger); background: var(--workbench-danger-soft); } @@ -7689,8 +7857,8 @@ svg { } .fleet-notification-devices li[data-state='unavailable'], .fleet-notification-devices li[data-state='failed'] { - border-color: rgba(183, 77, 72, 0.28); - background: #fffafa; + border-color: var(--workbench-danger-border); + background: var(--workbench-danger-soft); } .fleet-notification-devices li > span { min-width: 0; @@ -7726,27 +7894,27 @@ svg { } .fleet-notification-devices li[data-state='ready'] > small { color: var(--workbench-mint); - border-color: rgba(17, 119, 110, 0.3); + border-color: var(--workbench-mint-border); background: var(--workbench-mint-soft); } .fleet-notification-devices li[data-state='unavailable'] > small, .fleet-notification-devices li[data-state='failed'] > small { color: var(--workbench-danger); - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); background: var(--workbench-danger-soft); } .fleet-notification-devices li[data-tone='attention'] { - border-color: rgba(183, 77, 72, 0.28); - background: #fffafa; + border-color: var(--workbench-danger-border); + background: var(--workbench-danger-soft); } .fleet-notification-devices li[data-tone='ok'] > small { color: var(--workbench-mint); - border-color: rgba(17, 119, 110, 0.3); + border-color: var(--workbench-mint-border); background: var(--workbench-mint-soft); } .fleet-notification-devices li[data-tone='attention'] > small { color: var(--workbench-danger); - border-color: rgba(183, 77, 72, 0.3); + border-color: var(--workbench-danger-border); background: var(--workbench-danger-soft); } .fleet-notification-channel-picker { @@ -7760,7 +7928,7 @@ svg { gap: 0.4rem; padding: 0.32rem 0.5rem; border: 1px solid var(--workbench-border); - border-radius: 9px; + border-radius: var(--button-radius-compact); color: var(--workbench-text); background: var(--workbench-surface); font-size: 0.7rem; @@ -7798,7 +7966,7 @@ svg { font-size: 0.76rem; } .state-panel-button { - min-height: 2.2rem; + min-height: var(--button-height-compact); margin-left: 0.6rem; padding: 0.3rem 0.7rem; } @@ -7865,3 +8033,366 @@ svg { .field-block > label { margin-top: 0; } + +/* ========================================================================== + Button system: shared scale for every action control. + + Appended last on purpose. The stylesheet accumulated five overlapping eras of + button styling, and the older element-level rules out-rank single class + selectors, so a later layer is the only way to make one coherent scale win. + ========================================================================== */ + +/* Animal UI is themed through custom properties, which keeps us off its hashed classes. */ +:root { + --animal-primary-color: #11776e; + --animal-primary-color-hover: #0f6e66; + --animal-primary-color-active: #0d5f58; + --animal-primary-color-bg: #dff3e8; + --animal-error-color: #b64d48; + --animal-error-color-hover: #c4635e; + --animal-error-color-active: #8f3531; + --animal-text-color: #584a38; + --animal-text-color-secondary: #6f6350; + --animal-text-color-muted: #6f6350; + --animal-text-color-disabled: #6f6350; + --animal-border-color: #bdb29d; + --animal-border-color-hover: #0f6e66; + --animal-border-color-light: #d9d1bf; + --animal-bg-color: #fffdf7; + --animal-bg-color-secondary: #f8f4e8; + --animal-bg-color-disabled: #ece6da; + --animal-height-sm: 34px; + --animal-height-base: 38px; + --animal-height-lg: 42px; + --animal-border-radius-sm: 8px; + --animal-border-radius-base: 10px; + --animal-border-radius-lg: 12px; + --animal-border-width: 1px; + --animal-shadow-sm: none; + --animal-shadow-base: 0 3px 0 rgba(13, 95, 88, 0.28); + --animal-shadow-lg: 0 14px 30px rgba(74, 61, 43, 0.14); +} + +[class*='animal-btn-'] { + height: auto; + min-height: var(--button-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border-width: 1px; + border-radius: var(--button-radius); + box-shadow: none; + font-weight: 750; + white-space: normal; +} + +[class*='animal-btn-']:focus-visible { + outline: 3px solid var(--button-focus-ring); + outline-offset: 2px; +} + +[class*='animal-btn-small'] { + min-height: var(--button-height-compact); + padding: 0.3rem 0.7rem; + border-radius: var(--button-radius-compact); + font-size: 0.76rem; +} + +[class*='animal-btn-middle'] { + min-height: var(--button-height); + padding: 0.45rem 0.9rem; + font-size: 0.82rem; +} + +[class*='animal-btn-large'] { + min-height: var(--button-height-large); + padding: 0.55rem 1.1rem; +} + +[class*='animal-btn-icon'] { + width: var(--icon-button-size); + height: var(--icon-button-size); + min-height: var(--icon-button-size); + padding: 0; +} + +[class*='animal-btn-block'] { + display: flex; + width: 100%; +} + +/* The library ships "primary" as the cream 3D card button; the console reserves it for the mint action. */ +[class*='animal-btn-primary'] { + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + background: var(--button-primary-bg); + box-shadow: 0 3px 0 var(--button-primary-active); +} + +[class*='animal-btn-primary']:hover:not(:disabled) { + color: var(--button-primary-text); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); + box-shadow: 0 4px 0 var(--button-primary-active); + transform: translateY(-1px); +} + +[class*='animal-btn-primary']:active:not(:disabled) { + color: var(--button-primary-text); + border-color: var(--button-primary-active); + background: var(--button-primary-active); + box-shadow: 0 1px 0 var(--button-primary-active); + transform: translateY(1px); +} + +[class*='animal-btn-default'], +[class*='animal-btn-dashed'] { + color: var(--button-secondary-text); + border-color: var(--button-secondary-border); + background: var(--button-secondary-bg); +} + +[class*='animal-btn-default']:hover:not(:disabled), +[class*='animal-btn-dashed']:hover:not(:disabled) { + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); +} + +[class*='animal-btn-text'], +[class*='animal-btn-link'], +[class*='animal-btn-ghost'] { + color: var(--workbench-muted); + border-color: transparent; + background: transparent; + box-shadow: none; +} + +[class*='animal-btn-text']:hover:not(:disabled), +[class*='animal-btn-link']:hover:not(:disabled), +[class*='animal-btn-ghost']:hover:not(:disabled) { + color: var(--button-secondary-hover-text); + background: var(--button-secondary-hover-bg); +} + +/* The library paints danger text/link buttons white on transparent, which is invisible here. */ +[class*='animal-btn-danger'][class*='animal-btn-primary'] { + color: var(--button-danger-text); + border-color: var(--button-danger-bg); + background: var(--button-danger-bg); + box-shadow: 0 3px 0 var(--button-danger-active); +} + +[class*='animal-btn-danger'][class*='animal-btn-primary']:hover:not(:disabled) { + border-color: var(--button-danger-hover); + background: var(--button-danger-hover); +} + +[class*='animal-btn-danger'][class*='animal-btn-default'], +[class*='animal-btn-danger'][class*='animal-btn-dashed'] { + color: var(--button-danger-text); + border-color: rgba(182, 77, 72, 0.45); + background: var(--workbench-danger-soft); +} + +[class*='animal-btn-danger'][class*='animal-btn-text'], +[class*='animal-btn-danger'][class*='animal-btn-link'], +[class*='animal-btn-danger'][class*='animal-btn-ghost'] { + color: var(--button-danger-bg); + background: transparent; +} + +[class*='animal-btn-danger'][class*='animal-btn-text']:hover:not(:disabled), +[class*='animal-btn-danger'][class*='animal-btn-link']:hover:not(:disabled), +[class*='animal-btn-danger'][class*='animal-btn-ghost']:hover:not(:disabled) { + color: var(--button-danger-hover); + background: var(--workbench-danger-soft); +} + +[class*='animal-btn-'][class*='animal-btn-loading'] { + color: var(--button-primary-text); + border-color: var(--button-primary-active); + background: var(--button-primary-bg); +} + +[class*='animal-btn-']:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + box-shadow: none; + opacity: 1; + transform: none; +} + +/* Compact actions: secondary controls that sit inside panels, tables and drawers. */ +button.state-panel-button, +button.fleet-log-retention-actions .primary-action, +.fleet-log-retention-actions .primary-action, +button.fleet-notifications-retry-all, +button.fleet-messages-delete, +button.fleet-messages-search button, +.fleet-messages-search button, +button.fleet-notification-entry-button, +.identity-actions button, +.device-context-actions a, +.row-actions button, +.pagination button, +.batch-entry-actions button, +.fleet-card-admin-actions button, +.segmented-control button { + min-height: var(--button-height-compact); + gap: 0.35rem; + padding: 0.3rem 0.7rem; + border-radius: var(--button-radius-compact); + font-size: 0.76rem; + font-weight: 750; +} + +/* Secondary action inside a panel: keep it on the mint accent without the heavy fill. */ +button.fleet-notifications-retry-all, +.fleet-notifications-retry-all { + color: var(--button-primary-hover); + border: 1px solid var(--button-primary-bg); + background: var(--workbench-mint-soft); + box-shadow: none; +} + +button.fleet-notifications-retry-all:hover:not(:disabled), +.fleet-notifications-retry-all:hover:not(:disabled) { + color: var(--button-primary-text); + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); +} + +button.fleet-notifications-retry-all:disabled, +.fleet-notifications-retry-all:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; +} + +button.fleet-messages-search button, +.fleet-messages-search button { + color: var(--button-primary-text); + border: 1px solid var(--button-primary-bg); + background: var(--button-primary-bg); + box-shadow: none; +} + +button.fleet-messages-search button:hover:not(:disabled), +.fleet-messages-search button:hover:not(:disabled) { + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); +} + +button.fleet-messages-search button:disabled, +.fleet-messages-search button:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + opacity: 1; +} + +button.fleet-messages-delete, +button.danger-button, +.danger-button, +button.row-actions button.is-danger { + color: var(--button-danger-text); + border: 1px solid var(--button-danger-bg); + background: var(--button-danger-bg); + box-shadow: 0 2px 0 var(--button-danger-active); +} + +button.fleet-messages-delete:hover:not(:disabled), +.danger-button:hover:not(:disabled) { + color: var(--button-danger-text); + border-color: var(--button-danger-hover); + background: var(--button-danger-hover); +} + +button.fleet-messages-delete:disabled, +.danger-button:disabled { + color: var(--button-disabled-text); + border-color: var(--button-disabled-border); + background: var(--button-disabled-bg); + box-shadow: none; + opacity: 1; +} + +.identity-actions button, +.device-context-actions a { + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); + background: var(--button-secondary-bg); + box-shadow: none; +} + +.identity-actions button:hover:not(:disabled), +.device-context-actions a:hover { + color: var(--button-secondary-hover-text); + border-color: var(--button-secondary-hover-border); + background: var(--button-secondary-hover-bg); +} + +.device-context-primary { + color: var(--button-primary-text); + border-color: var(--button-primary-bg); + background: var(--button-primary-bg); +} + +.device-context-primary:hover { + border-color: var(--button-primary-hover); + background: var(--button-primary-hover); +} + +/* Filter chips stay pill shaped on purpose: they are toggles, not commands. */ +.fleet-messages-filter-chip { + min-height: var(--button-height-compact); + padding: 0.3rem 0.7rem; + color: var(--workbench-muted); + border: 1px solid var(--workbench-border-strong); + background: var(--workbench-surface); +} + +.fleet-messages-filter-chip[aria-pressed='true'] { + color: var(--button-primary-hover); + border-color: var(--button-primary-bg); + background: var(--workbench-mint-soft); +} + +/* Tabs and segmented controls keep their own geometry, but share the height scale. */ +.workbench-tabs button { + min-height: var(--button-height-large); +} + +.segmented-control button { + border-radius: var(--button-radius-compact); +} + +.segmented-control { + border-radius: 12px; +} + +button:focus-visible { + outline: 3px solid var(--button-focus-ring); + outline-offset: 2px; +} + +@media (max-width: 34rem) { + .workbench-heading > button.primary-action, + .workbench-heading > a.primary-action { + width: 100%; + } + + button.state-panel-button, + button.fleet-notifications-retry-all, + button.fleet-messages-delete, + .identity-actions button, + .device-context-actions a { + min-height: var(--button-height); + flex: 1 1 auto; + } +} diff --git a/apps/web/src/ui/button-contrast.audit.test.tsx b/apps/web/src/ui/button-contrast.audit.test.tsx new file mode 100644 index 0000000..3a50899 --- /dev/null +++ b/apps/web/src/ui/button-contrast.audit.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { readFile } from 'node:fs/promises'; + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AppShell } from '../app-shell.js'; +import { flatten } from './test-css.js'; + +function channel(value: number): number { + const ratio = value / 255; + return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; +} + +function luminance(rgb: readonly [number, number, number]): number { + return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]); +} + +function parseColor(input: string): readonly [number, number, number] | null { + const m = input.match(/rgba?\(([^)]+)\)/); + if (!m) return null; + const parts = m[1].split(',').map((p) => Number.parseFloat(p.trim())); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +function contrast( + fore: readonly [number, number, number], + back: readonly [number, number, number], +) { + const l1 = luminance(fore); + const l2 = luminance(back); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +function effectiveBackground(element: Element): readonly [number, number, number] { + let node: Element | null = element; + while (node) { + const value = parseColor(window.getComputedStyle(node).backgroundColor); + const isTransparent = + !value || + (value[0] === 0 && + value[1] === 0 && + value[2] === 0 && + getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)'); + if (!isTransparent) return value ?? [255, 255, 255]; + node = node.parentElement; + } + return [233, 244, 223]; +} + +const ROUTES = [ + '/fleet', + '/fleet/messages', + '/fleet/notifications', + '/fleet/logs', + '/automation', + '/jobs', + '/audit', + '/settings/system', +] as const; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('painted control contrast', () => { + it('keeps every rendered control at WCAG AA', { timeout: 30_000 }, async () => { + const raw = await readFile( + new URL('file:///Users/chick/.local/opt/multi-simadmin/apps/web/src/styles.css'), + 'utf8', + ); + const style = document.createElement('style'); + style.textContent = flatten(raw); + document.head.append(style); + + const failures: string[] = []; + for (const pathname of ROUTES) { + const { unmount } = render(); + await new Promise((done) => window.setTimeout(done, 40)); + for (const element of document.querySelectorAll('button, a, [role="button"]')) { + const cs = window.getComputedStyle(element); + const fore = parseColor(cs.color); + if (!fore) continue; + const back = effectiveBackground(element); + const ratio = contrast(fore, back); + if (ratio >= 4.5) continue; + const label = (element.textContent ?? '').trim().slice(0, 30) || element.className; + failures.push( + `${pathname} ${ratio.toFixed(2)}:1 fg=${cs.color} bg=rgb(${back.join(',')}) <${element.tagName.toLowerCase()} class="${element.className}"> "${label}"`, + ); + } + unmount(); + } + + for (const line of failures) console.log(line); + expect(failures).toEqual([]); + }); +}); diff --git a/apps/web/src/ui/button-system.test.ts b/apps/web/src/ui/button-system.test.ts new file mode 100644 index 0000000..7f6c6a4 --- /dev/null +++ b/apps/web/src/ui/button-system.test.ts @@ -0,0 +1,268 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +type Rule = { selector: string; declarations: Record }; + +/** + * The stylesheet is layered by years of incremental patches, so asserting on raw substrings + * proves nothing about what the browser finally paints. These helpers resolve the cascade. + */ +function parseDeclarations(body: string): Record { + const declarations: Record = {}; + for (const part of body.split(';')) { + const separator = part.indexOf(':'); + if (separator === -1) continue; + const property = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (property && value) declarations[property] = value; + } + return declarations; +} + +function collectRules(css: string, rules: Rule[] = []): Rule[] { + let index = 0; + let preludeStart = 0; + while (index < css.length) { + if (css[index] !== '{') { + index += 1; + continue; + } + const selector = css.slice(preludeStart, index).trim(); + let cursor = index + 1; + let depth = 1; + while (cursor < css.length && depth > 0) { + if (css[cursor] === '{') depth += 1; + else if (css[cursor] === '}') depth -= 1; + cursor += 1; + } + const body = css.slice(index + 1, cursor - 1); + if (selector.startsWith('@')) { + collectRules(body, rules); + } else if (selector) { + rules.push({ selector, declarations: parseDeclarations(body) }); + } + preludeStart = cursor; + index = cursor; + } + return rules; +} + +async function loadStylesheet() { + const css = await readFile(new URL('../styles.css', import.meta.url), 'utf8'); + const rules = collectRules(css.replace(/\/\*[\s\S]*?\*\//g, ''), []); + const tokens: Record = {}; + for (const rule of rules) { + if (rule.selector !== ':root') { + continue; + } + for (const [property, value] of Object.entries(rule.declarations)) { + if (property.startsWith('--')) tokens[property] = value; + } + } + const wins = (target: string, property: string) => { + let winner: string | undefined; + for (const rule of rules) { + if (!rule.selector.split(',').some((entry) => entry.replace(/\s+/g, ' ').trim() === target)) + continue; + const value = rule.declarations[property]; + if (value !== undefined) winner = value; + } + return winner; + }; + const resolve = (value: string | undefined): string | undefined => { + if (value === undefined) return undefined; + return value.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name: string) => tokens[name] ?? ''); + }; + const exact = (selector: string, property: string) => + rules.filter((rule) => rule.selector.replace(/\s+/g, ' ') === selector).at(-1)?.declarations[ + property + ]; + return { rules, tokens, wins, resolve, exact }; +} + +function channel(value: number): number { + const ratio = value / 255; + return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; +} + +function contrastRatio(foreground: string, background: string): number { + const parse = (input: string) => { + const hex = input.trim().match(/^#([0-9a-f]{6})$/i); + if (!hex) throw new Error(`expected a 6 digit hex colour, got "${input}"`); + const int = Number.parseInt(hex[1], 16); + return [int >> 16, (int >> 8) & 0xff, int & 0xff] as const; + }; + const luminance = (input: string) => { + const [red, green, blue] = parse(input); + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); + }; + const lighter = Math.max(luminance(foreground), luminance(background)); + const darker = Math.min(luminance(foreground), luminance(background)); + return (lighter + 0.05) / (darker + 0.05); +} + +describe('button system', () => { + it('exposes one compact height and radius scale', async () => { + const { tokens } = await loadStylesheet(); + + expect(tokens['--button-height']).toBe('2.375rem'); + expect(tokens['--button-height-compact']).toBe('2.125rem'); + expect(tokens['--button-height-large']).toBe('2.625rem'); + expect(tokens['--icon-button-size']).toBe('2.5rem'); + expect(tokens['--button-radius']).toBe('10px'); + }); + + it('paints the primary action with the accessible token pair, not the old teal pill', async () => { + const { rules, wins, resolve } = await loadStylesheet(); + + // A bare `.primary-action` selector has specificity (0,1,0) and loses to the generic + // `button:not([class*='animal-'])` surface rule at (0,1,1). That is what turned the label + // white on cream, so the winning declarations must live on an element-qualified selector. + const bare = rules.filter( + (rule) => + rule.declarations.background !== undefined && + rule.selector.split(',').some((entry) => entry.trim() === '.primary-action'), + ); + expect(bare.map((rule) => rule.selector)).toEqual([]); + + expect(wins('button.primary-action', 'border-radius')).toBe('var(--button-radius)'); + const background = resolve(wins('button.primary-action', 'background')); + const color = resolve(wins('button.primary-action', 'color')); + + expect(background).toMatch(/^#[0-9a-f]{6}$/i); + expect(color).toMatch(/^#[0-9a-f]{6}$/i); + expect(contrastRatio(color as string, background as string)).toBeGreaterThanOrEqual(4.5); + }); + + it('keeps disabled actions legible instead of fading them out', async () => { + const { wins, resolve, tokens } = await loadStylesheet(); + + expect(wins('button.primary-action:disabled', 'opacity')).toBe('1'); + expect(wins('button:disabled', 'opacity')).toBe('1'); + expect( + contrastRatio( + resolve(tokens['--button-disabled-text']) as string, + resolve(tokens['--button-disabled-bg']) as string, + ), + ).toBeGreaterThanOrEqual(3); + }); + + it('uses one accessible field scale shared by inputs, selects and textareas', async () => { + const { tokens, exact } = await loadStylesheet(); + + expect(tokens['--field-height']).toBe('2.375rem'); + expect(tokens['--field-radius']).toBe('10px'); + expect(tokens['--field-border']).toBe('var(--line-strong)'); + expect(tokens['--field-focus-border']).toBe('var(--button-focus-ring)'); + expect(exact('button, input, select, textarea', 'border')).toBe( + '1px solid var(--field-border)', + ); + expect(exact('button, input, select, textarea', 'background')).toBe('var(--field-bg)'); + expect(exact('input:focus, select:focus, textarea:focus', 'border-color')).toBe( + 'var(--field-focus-border)', + ); + }); + + it('normalises semantic surfaces in notification, log and message centres', async () => { + const { tokens, rules } = await loadStylesheet(); + const semanticNames = [ + '--workbench-mint-border', + '--workbench-warning-border', + '--workbench-warning-text', + '--workbench-warning-soft', + '--workbench-danger-border', + ]; + + expect(tokens['--workbench-warning-text']).toBe('#825f0c'); + expect(semanticNames.every((name) => /^#[0-9a-f]{6}$/i.test(tokens[name] ?? ''))).toBe(true); + expect( + contrastRatio(tokens['--workbench-warning-text'], tokens['--workbench-warning-soft']), + ).toBeGreaterThanOrEqual(4.5); + expect( + rules + .filter((rule) => + [ + '.fleet-notifications-retry-all', + '.fleet-messages-delete', + '.fleet-log-filters select', + ].some((selector) => rule.selector.split(',').some((entry) => entry.trim() === selector)), + ) + .every((rule) => !Object.values(rule.declarations).join(' ').includes('rgba(')), + ).toBe(true); + + const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace( + /\/\*[\s\S]*?\*\//g, + '', + ); + expect(css).not.toMatch( + /#8a6722|#fff8e8|#fffafa|rgba\(17,\s*119,\s*110|rgba\(170,\s*120,\s*35|rgba\(183,\s*77,\s*72/, + ); + }); + + it('keeps workbench text and focus indicators on the accessible brand scale', async () => { + const { tokens, resolve } = await loadStylesheet(); + + expect(tokens['--workbench-mint']).toBe('#0f6e66'); + expect(tokens['--workbench-muted']).toBe('#6f6350'); + expect(tokens['--button-focus-ring']).toBe('#0f6e66'); + expect(tokens['--animal-primary-color-hover']).toBe('#0f6e66'); + expect(tokens['--animal-text-color-muted']).toBe('#6f6350'); + expect( + contrastRatio( + resolve(tokens['--workbench-muted']) as string, + resolve(tokens['--animal-canvas-fallback'] ?? '#e9f4df') as string, + ), + ).toBeGreaterThanOrEqual(4.5); + }); + + it('does not resurrect low-contrast legacy accents', async () => { + const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace( + /\/\*[\s\S]*?\*\//g, + '', + ); + + expect(css).not.toMatch(/#19b8aa|#178f84|#109d92|rgba\(25,\s*184,\s*170/i); + expect(css).not.toMatch(/#d95757|rgba\(217,\s*87,\s*87/i); + expect(css).not.toMatch(/#8f806b|#81745f|#8d816e|#766a58|#7f725f/); + }); + + it('normalises Animal UI buttons through attribute selectors, not hashed classes', async () => { + const { rules, wins } = await loadStylesheet(); + const selectors = rules.map((rule) => rule.selector); + + expect(selectors.some((selector) => selector.includes("[class*='animal-btn-']"))).toBe(true); + expect(selectors.some((s) => s.includes("[class*='animal-btn-small']"))).toBe(true); + // Hashed module classes such as animal-btn-QoKe1 are regenerated on every library upgrade + // and silently stop matching, so only the semantic suffixes may appear here. + const semantic = + 'small|middle|large|icon|block|primary|default|dashed|text|link|ghost|danger|loading'; + expect(selectors.join(' ')).not.toMatch( + new RegExp(`animal-btn-(?!${semantic}\\b)[A-Za-z0-9]{4,}`), + ); + expect(wins("[class*='animal-btn-']", 'border-radius')).toBe('var(--button-radius)'); + }); + + it('gives icon buttons a square target that matches the text scale', async () => { + const { wins } = await loadStylesheet(); + + expect(wins('button.icon-button', 'width')).toBe('var(--icon-button-size)'); + expect(wins('button.icon-button', 'border-radius')).toBe('var(--button-radius)'); + expect(wins('button.fleet-card-menu-trigger', 'border-radius')).toBe('var(--button-radius)'); + expect(wins('button.row-action-trigger', 'min-height')).toBe('var(--icon-button-size)'); + }); + + it('drops the capsule radius from every primary action variant', async () => { + const { rules } = await loadStylesheet(); + + const offenders = rules + .filter((rule) => + rule.selector + .split(',') + .some((entry) => /(^|[\s>+~])\.primary-action(?![-\w])/.test(entry.trim())), + ) + .filter((rule) => /999px|50%/.test(Object.values(rule.declarations).join(' '))); + + expect(offenders.map((rule) => rule.selector)).toEqual([]); + }); +}); diff --git a/apps/web/src/ui/test-css.ts b/apps/web/src/ui/test-css.ts new file mode 100644 index 0000000..d34f2d8 --- /dev/null +++ b/apps/web/src/ui/test-css.ts @@ -0,0 +1,40 @@ +// jsdom can read custom properties, but computed values that inherit through multiple +// layers are easier to audit when the stylesheet is flattened to literals first. +export function flatten(css: string): string { + const tokens: Record = {}; + const root = /:root\s*\{([^}]*)\}/g; + let match: RegExpExecArray | null; + while ((match = root.exec(css))) { + for (const declaration of match[1]!.split(';')) { + const separator = declaration.indexOf(':'); + if (separator === -1) continue; + const property = declaration.slice(0, separator).trim(); + const value = declaration.slice(separator + 1).trim(); + if (property.startsWith('--')) tokens[property] = value; + } + } + + const resolved: Record = {}; + function resolve(name: string, seen: ReadonlySet = new Set()): string { + const cacheKey = `${name}\u0000${[...seen].join('\u0001')}`; + if (resolved[cacheKey] !== undefined) return resolved[cacheKey]!; + if (seen.has(name)) return ''; + const raw = tokens[name]; + if (raw === undefined) return ''; + return substitute(raw, new Set(seen).add(name)); + } + + function substitute(text: string, seen: ReadonlySet = new Set()): string { + let output = text; + for (let guard = 0; guard < 12 && /var\(/.test(output); guard += 1) { + output = output.replace( + /var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^()]*))?\)/gi, + (_, name: string, fallback?: string) => + resolve(name, seen) || (fallback ? substitute(fallback, seen) : ''), + ); + } + return output; + } + + return substitute(css.replace(/^\s*--[a-z0-9-]+\s*:[^;]*;\s*$/gm, '')); +}