🐶 Sync 2026-10-20 22:40:23

This commit is contained in:
github-actions[bot] committed 2026-10-20 22:40:23 +08:00
1 parent e813f5cc54
commit 64cea2243a
216 files changed
+11913 -2968

No files matched your search

@@ -9,11 +9,11 @@
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
* Copyright 2011 Twitter, Inc
*/
@layer tokens,base,theme,page;@layer tokens{:root{--fs-shadow-pop:0 12px 34px rgba(0,0,0,.30);--fs-focus-ring:0 0 0 3px var(--fs-accent-soft);--fs-focus-ring-solo:0 0 0 2px var(--fs-panel),0 0 0 5px var(--fs-accent);--fs-focus-ring-invalid:0 0 0 3px var(--fs-danger-fill);--fs-row-rail:inset 2px 0 0 var(--fs-accent);--fs-hairline:1px solid var(--fs-border);--fs-field-transition:border-color var(--fs-dur),box-shadow var(--fs-dur);--fs-scrim:rgba(0,0,0,.7);--fs-good-soft:color-mix(in srgb,var(--fs-good) 12%,transparent);--fs-warn-soft:color-mix(in srgb,var(--fs-warn) 12%,transparent);--fs-danger-soft:color-mix(in srgb,var(--fs-danger) 12%,transparent);--fs-accent-fill:color-mix(in srgb,var(--fs-accent) 18%,transparent);--fs-good-fill:color-mix(in srgb,var(--fs-good) 18%,transparent);--fs-warn-fill:color-mix(in srgb,var(--fs-warn) 18%,transparent);--fs-danger-fill:color-mix(in srgb,var(--fs-danger) 18%,transparent);--fs-accent-line:color-mix(in srgb,var(--fs-accent) 40%,transparent);--fs-good-line:color-mix(in srgb,var(--fs-good) 40%,transparent);--fs-warn-line:color-mix(in srgb,var(--fs-warn) 40%,transparent);--fs-danger-line:color-mix(in srgb,var(--fs-danger) 40%,transparent);--fs-accent-line-hi:color-mix(in srgb,var(--fs-accent) 55%,transparent);--fs-good-line-hi:color-mix(in srgb,var(--fs-good) 55%,transparent);--fs-warn-line-hi:color-mix(in srgb,var(--fs-warn) 55%,transparent);--fs-danger-line-hi:color-mix(in srgb,var(--fs-danger) 55%,transparent);--fs-icon-refresh:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");--fs-icon-refresh-sz:calc(18px * var(--fs-density-type));--fs-hover-lift:brightness(.90);--fs-dur:.15s;--fs-dur-move:.2s;--fs-dur-fade:.25s;--fs-dur-fill:.4s;--fs-content-max:1280px;--fs-content-pad:calc(28px * var(--fs-density-space));--fs-sidebar-w:calc(224px * var(--fs-density-box));--fs-rail-w:calc(68px * var(--fs-density-box));--fs-content-min:500px;--fs-bar-bg:var(--fs-panel);--fs-bar-ctl-bg:transparent;--fs-select-chevron:linear-gradient(-45deg,transparent 45%,var(--fs-dim) 45% 55%,transparent 55%),linear-gradient(45deg,transparent 45%,var(--fs-dim) 45% 55%,transparent 55%);--fs-bar-h:calc(46px * var(--fs-density-box));--fs-btn-size:calc(34px * var(--fs-density-box));--fs-radius-base:12px;--fs-radius-lg:var(--fs-radius-base);--fs-radius:calc(var(--fs-radius-base) * 5 / 6);--fs-radius-sm:calc(var(--fs-radius-base) * 2 / 3);--fs-radius-pill:999px;--fs-card-pad:calc(16px * var(--fs-density-space));--fs-card-gap:calc(16px * var(--fs-density-space));--fs-font-sans:"Manrope",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;--fs-font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;--fs-density-type:1;--fs-density-space:1;--fs-density-box:1;--fs-ctl-h-sm:calc(30px * var(--fs-density-box));--fs-ctl-h:calc(38px * var(--fs-density-box));--fs-ctl-h-lg:calc(44px * var(--fs-density-box));--fs-type-2xs:calc(10px * var(--fs-density-type));--fs-type-xs:calc(11px * var(--fs-density-type));--fs-type:calc(13px * var(--fs-density-type));--fs-type-lg:calc(16px * var(--fs-density-type));--fs-type-xl:calc(20px * var(--fs-density-type));--fs-type-2xl:calc(26px * var(--fs-density-type));--fs-weight-normal:400;--fs-weight:600;--fs-weight-bold:700;--fs-leading:1.5;--fs-leading-tight:1.2;--fs-eyebrow-tracking:.06em;--fs-eyebrow-weight:700;--fs-eyebrow-color:var(--fs-faint);--fs-space-0-5:calc(2px * var(--fs-density-space));--fs-space-1:calc(4px * var(--fs-density-space));--fs-space-1-5:calc(6px * var(--fs-density-space));--fs-space-2:calc(8px * var(--fs-density-space));--fs-space-2-5:calc(10px * var(--fs-density-space));--fs-space-3:calc(12px * var(--fs-density-space));--fs-space-3-5:calc(14px * var(--fs-density-space));--fs-space-4:calc(16px * var(--fs-density-space));--fs-space-5:calc(20px * var(--fs-density-space));--fs-space-6:calc(24px * var(--fs-density-space));--fs-space-7:calc(28px * var(--fs-density-space));--fs-space-8:calc(32px * var(--fs-density-space));--fs-space-10:calc(40px * var(--fs-density-space));--fs-spin-size:var(--fs-space-5);--fs-control-h:calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);--fs-field-max:440px;--fs-z-raise:2;--fs-z-sticky:50;--fs-z-flyout:70;--fs-z-header:800;--fs-z-popover:850;--fs-z-overlay:900;--fs-z-tooltip:1000;--fs-z-dropdown:1100;--background-color-high:var(--fs-panel);--background-color-medium:color-mix(in srgb,var(--fs-panel) 55%,var(--fs-panel2));--background-color-low:var(--fs-panel2);--text-color-highest:var(--fs-text);--text-color-high:var(--fs-text);--text-color-medium:var(--fs-dim);--text-color-low:var(--fs-faint);--border-color-high:color-mix(in srgb,var(--fs-border) Line truncated
@layer tokens,base,theme,page;@layer tokens{:root{--fs-shadow-pop:0 12px 34px rgba(0,0,0,.30);--fs-focus-ring:0 0 0 3px var(--fs-accent-soft);--fs-focus-ring-solo:0 0 0 2px var(--fs-panel),0 0 0 5px var(--fs-accent);--fs-focus-ring-invalid:0 0 0 3px var(--fs-danger-fill);--fs-row-rail:inset 2px 0 0 var(--fs-accent);--fs-hairline:1px solid var(--fs-border);--fs-field-transition:border-color var(--fs-dur),box-shadow var(--fs-dur);--fs-scrim:rgba(0,0,0,.7);--fs-good-soft:color-mix(in srgb,var(--fs-good) 12%,transparent);--fs-danger-soft:color-mix(in srgb,var(--fs-danger) 12%,transparent);--fs-accent-fill:color-mix(in srgb,var(--fs-accent) 18%,transparent);--fs-good-fill:color-mix(in srgb,var(--fs-good) 18%,transparent);--fs-warn-fill:color-mix(in srgb,var(--fs-warn) 18%,transparent);--fs-danger-fill:color-mix(in srgb,var(--fs-danger) 18%,transparent);--fs-accent-line:color-mix(in srgb,var(--fs-accent) 40%,transparent);--fs-good-line:color-mix(in srgb,var(--fs-good) 40%,transparent);--fs-danger-line:color-mix(in srgb,var(--fs-danger) 40%,transparent);--fs-accent-line-hi:color-mix(in srgb,var(--fs-accent) 55%,transparent);--fs-good-line-hi:color-mix(in srgb,var(--fs-good) 55%,transparent);--fs-danger-line-hi:color-mix(in srgb,var(--fs-danger) 55%,transparent);--fs-icon-refresh:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");--fs-icon-refresh-sz:calc(18px * var(--fs-density-type));--fs-hover-lift:brightness(.90);--fs-dur:.15s;--fs-dur-move:.2s;--fs-dur-fade:.25s;--fs-dur-fill:.4s;--fs-content-max:1280px;--fs-content-pad:calc(28px * var(--fs-density-space));--fs-sidebar-w:calc(224px * var(--fs-density-box));--fs-rail-w:calc(68px * var(--fs-density-box));--fs-content-min:500px;--fs-bar-bg:var(--fs-panel);--fs-select-chevron:linear-gradient(-45deg,transparent 45%,var(--fs-dim) 45% 55%,transparent 55%),linear-gradient(45deg,transparent 45%,var(--fs-dim) 45% 55%,transparent 55%);--fs-bar-h:calc(46px * var(--fs-density-box));--fs-btn-size:calc(34px * var(--fs-density-box));--fs-radius-base:12px;--fs-radius-lg:var(--fs-radius-base);--fs-radius:calc(var(--fs-radius-base) * 5 / 6);--fs-radius-sm:calc(var(--fs-radius-base) * 2 / 3);--fs-radius-pill:999px;--fs-card-pad:calc(16px * var(--fs-density-space));--fs-card-gap:calc(16px * var(--fs-density-space));--fs-font-sans:"Manrope",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;--fs-font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;--fs-density-type:1;--fs-density-space:1;--fs-density-box:1;--fs-ctl-h-sm:calc(30px * var(--fs-density-box));--fs-ctl-h:calc(38px * var(--fs-density-box));--fs-ctl-h-lg:calc(44px * var(--fs-density-box));--fs-type-2xs:calc(10px * var(--fs-density-type));--fs-type-xs:calc(11px * var(--fs-density-type));--fs-type:calc(13px * var(--fs-density-type));--fs-type-lg:calc(16px * var(--fs-density-type));--fs-type-xl:calc(20px * var(--fs-density-type));--fs-type-2xl:calc(26px * var(--fs-density-type));--fs-weight-normal:400;--fs-weight:600;--fs-weight-bold:700;--fs-leading:1.5;--fs-leading-tight:1.2;--fs-eyebrow-tracking:.06em;--fs-eyebrow-weight:700;--fs-eyebrow-color:var(--fs-faint);--fs-space-0-5:calc(2px * var(--fs-density-space));--fs-space-1:calc(4px * var(--fs-density-space));--fs-space-1-5:calc(6px * var(--fs-density-space));--fs-space-2:calc(8px * var(--fs-density-space));--fs-space-2-5:calc(10px * var(--fs-density-space));--fs-space-3:calc(12px * var(--fs-density-space));--fs-space-3-5:calc(14px * var(--fs-density-space));--fs-space-4:calc(16px * var(--fs-density-space));--fs-space-5:calc(20px * var(--fs-density-space));--fs-space-6:calc(24px * var(--fs-density-space));--fs-space-7:calc(28px * var(--fs-density-space));--fs-space-8:calc(32px * var(--fs-density-space));--fs-space-10:calc(40px * var(--fs-density-space));--fs-control-h:calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);--fs-field-max:440px;--fs-z-raise:2;--fs-z-sticky:50;--fs-z-flyout:70;--fs-z-header:800;--fs-z-popover:850;--fs-z-overlay:900;--fs-z-tooltip:1000;--fs-z-dropdown:1100;--background-color-high:var(--fs-panel);--background-color-medium:color-mix(in srgb,var(--fs-panel) 55%,var(--fs-panel2));--background-color-low:var(--fs-panel2);--text-color-highest:var(--fs-text);--text-color-high:var(--fs-text);--text-color-medium:var(--fs-dim);--text-color-low:var(--fs-faint);--border-color-high:color-mix(in srgb,var(--fs-border) 60%,var(--fs-text));--border-color-medium:var(--fs-border);--border-color-low:color-mix(in srgb,var(--fs-border) 55%,var(--fs-panel));--primary-color-high:var(--fs-accent);--primary-color-medium:color-mix(in oklch,var(--fs-accent) 78%,var(--fs-dim));--primaryLine truncated
@supports (width:round(1px,1px)){:root{--fs-type-2xs:calc(round(10px * var(--fs-density-type),1px));--fs-type-xs:calc(round(11px * var(--fs-density-type),1px));--fs-type:calc(round(13px * var(--fs-density-type),1px));--fs-type-lg:calc(round(16px * var(--fs-density-type),1px));--fs-type-xl:calc(round(20px * var(--fs-density-type),1px));--fs-type-2xl:calc(round(26px * var(--fs-density-type),1px));--fs-control-h:calc(round(up,calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px),4px))}
}
:root[data-density="compact"]{--fs-density-type:.9;--fs-density-box:.85;--fs-density-space:.65}
:root[data-density="large"]{--fs-density-type:1.15;--fs-density-box:1.15;--fs-density-space:1}
:root[data-density="large"]{--fs-density-type:1.15;--fs-density-box:1.15}
:root[data-darkmode="true"]{--fs-disabled-opacity:.45;--fs-hover-lift:brightness(1.15);color-scheme:dark}
:root{--fs-bg:var(--fs-bg-base);--fs-accent:var(--fs-accent-base);--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 10%,transparent);--fs-panel:var(--fs-panel-base);--fs-panel2:var(--fs-panel2-base);--fs-border:var(--fs-border-base);--fs-good:var(--fs-good-base);--fs-warn:var(--fs-warn-base);--fs-danger:var(--fs-danger-base);--fs-placeholder-mix:55%;--fs-placeholder:color-mix(in oklch,var(--fs-text) var(--fs-placeholder-mix),var(--fs-panel2));--fs-accent-h:0;--fs-tint-h:0;--fs-good-h:0;--fs-warn-h:0;--fs-danger-h:0;--fs-tint-strength:1;--fs-login-bg-url:none;--fs-photo-dim:74%;--fs-photo-scrim:color-mix(in srgb,var(--fs-bg-base) var(--fs-photo-dim),transparent);--fs-pattern-url:none;--fs-pattern-size:440px;--fs-pattern-strength:.2;--fs-tint-c:calc((.016 + .012 * max(0,cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength))}
:root[data-darkmode="true"]{--fs-tint-c:calc((.011 + .016 * max(0,cos((var(--fs-tint-h) - 258) * 1deg)) - .004 * max(0,cos((var(--fs-tint-h) - 55) * 1deg))) * var(--fs-tint-strength));--fs-placeholder-mix:64%}
@@ -22,21 +22,21 @@
:root[data-good="hue"]{--fs-good:oklch(from var(--fs-good-base) l c var(--fs-good-h))}
:root[data-warn="hue"]{--fs-warn:oklch(from var(--fs-warn-base) l c var(--fs-warn-h))}
:root[data-danger="hue"]{--fs-danger:oklch(from var(--fs-danger-base) l c var(--fs-danger-h))}
:root[data-accent="hex"][data-accent]{--fs-on-accent:oklch(from var(--fs-accent) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-good="hex"][data-good]{--fs-on-good:oklch(from var(--fs-good) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-warn="hex"][data-warn]{--fs-on-warn:oklch(from var(--fs-warn) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-danger="hex"][data-danger]{--fs-on-danger:oklch(from var(--fs-danger) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-accent="hex"][data-accent][data-accent]{--fs-on-accent:oklch(from var(--fs-accent) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-good="hex"][data-good][data-good]{--fs-on-good:oklch(from var(--fs-good) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-warn="hex"][data-warn][data-warn]{--fs-on-warn:oklch(from var(--fs-warn) clamp(0,(l - .62) * -100,1) 0 0)}
:root[data-danger="hex"][data-danger][data-danger]{--fs-on-danger:oklch(from var(--fs-danger) clamp(0,(l - .62) * -100,1) 0 0)}
:root,:root[data-palette="footstrap"]{--fs-bg-base:#f6f8fa;--fs-panel-base:#fff;--fs-panel2-base:#f6f8fa;--fs-border-base:#d0d7de;--fs-text:#1f2328;--fs-dim:#33383d;--fs-faint:#3d4348;--fs-accent-base:#0969da;--fs-track:#eaeef2;--fs-good-base:#197c36;--fs-warn-base:#946300;--fs-danger-base:#cf222e;--fs-on-accent:#fff;--fs-on-good:#fff;--fs-on-warn:#fff;--fs-on-danger:#fff}
:root[data-darkmode="true"],:root[data-palette="footstrap"][data-darkmode="true"]{--fs-bg-base:#1c2128;--fs-panel-base:#22272e;--fs-panel2-base:#2d333b;--fs-border-base:#444c56;--fs-text:#adbac7;--fs-dim:#8f9cab;--fs-faint:#959ca5;--fs-accent-base:#569df5;--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 15%,transparent);--fs-track:#373e47;--fs-good-base:#5aad5d;--fs-warn-base:#c79128;--fs-danger-base:#ea7a74;--fs-on-accent:#10151c;--fs-on-good:#10151c;--fs-on-warn:#10151c;--fs-on-danger:#10151c}
:root[data-palette="hicontrast"]{--fs-bg-base:#eef1f6;--fs-panel-base:#fff;--fs-panel2-base:#f6f8fb;--fs-border-base:#e4e9f1;--fs-text:#101725;--fs-dim:#272f3d;--fs-faint:#2e3745;--fs-accent-base:#0b6fbd;--fs-track:#eaeef4;--fs-good-base:#0f7a52;--fs-warn-base:#8a5a00;--fs-danger-base:#c02b2b;--fs-on-accent:#fff;--fs-on-good:#fff;--fs-on-warn:#fff;--fs-on-danger:#fff}
:root[data-palette="hicontrast"]{--fs-bg-base:#eef1f6;--fs-panel2-base:#f6f8fb;--fs-border-base:#e4e9f1;--fs-text:#101725;--fs-dim:#272f3d;--fs-faint:#2e3745;--fs-accent-base:#0b6fbd;--fs-track:#eaeef4;--fs-good-base:#0f7a52;--fs-warn-base:#8a5a00;--fs-danger-base:#c02b2b}
:root[data-palette="hicontrast"][data-darkmode="true"]{--fs-bg-base:#0a0d13;--fs-panel-base:#111620;--fs-panel2-base:#161c28;--fs-border-base:#232b3a;--fs-text:#e8edf5;--fs-dim:#9ba6b8;--fs-faint:#8f9aad;--fs-accent-base:#3ba7ff;--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 14%,transparent);--fs-track:#1b2230;--fs-good-base:#43e0a0;--fs-warn-base:#ffb454;--fs-danger-base:#ff6b6b;--fs-on-accent:#0a0d13;--fs-on-good:#0a0d13;--fs-on-warn:#0a0d13;--fs-on-danger:#0a0d13}
:root[data-palette="bootstrap"]{--fs-bg-base:#fff;--fs-panel-base:#fff;--fs-panel2-base:#f5f5f5;--fs-border-base:#ccc;--fs-text:#232323;--fs-dim:#383838;--fs-faint:#424242;--fs-accent-base:#1564c0;--fs-track:#eee;--fs-good-base:#007936;--fs-warn-base:#826706;--fs-danger-base:#d00000;--fs-on-accent:#fff;--fs-on-good:#fff;--fs-on-warn:#fff;--fs-on-danger:#fff}
:root[data-palette="bootstrap"]{--fs-bg-base:#fff;--fs-panel2-base:#f5f5f5;--fs-border-base:#ccc;--fs-text:#232323;--fs-dim:#383838;--fs-faint:#424242;--fs-accent-base:#1564c0;--fs-track:#eee;--fs-good-base:#007936;--fs-warn-base:#826706;--fs-danger-base:#d00000}
:root[data-palette="bootstrap"][data-darkmode="true"]{--fs-bg-base:#222;--fs-panel-base:#282828;--fs-panel2-base:#303030;--fs-border-base:#444;--fs-text:#bfbfbf;--fs-dim:#a6a6a6;--fs-faint:#9a9a9a;--fs-accent-base:#53a4c2;--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 15%,transparent);--fs-track:#333;--fs-good-base:#1aaf7b;--fs-warn-base:#aa9968;--fs-danger-base:#dd807e;--fs-on-accent:#222;--fs-on-good:#222;--fs-on-warn:#222;--fs-on-danger:#222}
:root[data-palette="2020"]{--fs-bg-base:#fff;--fs-panel-base:#fff;--fs-panel2-base:#eef4f8;--fs-border-base:#c3d2dd;--fs-text:#10202b;--fs-dim:#2c4150;--fs-faint:#3d5566;--fs-accent-base:#036c92;--fs-track:#e3ebf1;--fs-good-base:#276e2a;--fs-warn-base:#8a5a00;--fs-danger-base:#c1121f;--fs-on-accent:#fff;--fs-on-good:#fff;--fs-on-warn:#fff;--fs-on-danger:#fff}
:root[data-palette="2020"]{--fs-bg-base:#fff;--fs-panel2-base:#eef4f8;--fs-border-base:#c3d2dd;--fs-text:#10202b;--fs-dim:#2c4150;--fs-faint:#3d5566;--fs-accent-base:#036c92;--fs-track:#e3ebf1;--fs-good-base:#276e2a;--fs-warn-base:#8a5a00;--fs-danger-base:#c1121f}
:root[data-palette="2020"][data-darkmode="true"]{--fs-bg-base:#002b49;--fs-panel-base:#0a3a58;--fs-panel2-base:#0e4362;--fs-border-base:#1d5a7d;--fs-text:#e6f1f7;--fs-dim:#b8cfdd;--fs-faint:#9dbccd;--fs-accent-base:#35c6ea;--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 15%,transparent);--fs-track:#123c56;--fs-good-base:#74cc74;--fs-warn-base:#eab346;--fs-danger-base:#ff9d9d;--fs-on-accent:#002b49;--fs-on-good:#002b49;--fs-on-warn:#002b49;--fs-on-danger:#002b49}
:root[data-palette="forum"]{--fs-bg-base:#fff;--fs-panel-base:#fff;--fs-panel2-base:#f0f0f0;--fs-border-base:#ddd;--fs-text:#222;--fs-dim:#3d3d3d;--fs-faint:#4a4a4a;--fs-accent-base:#0073ac;--fs-track:#eee;--fs-good-base:#008000;--fs-warn-base:#717100;--fs-danger-base:#c63c1b;--fs-on-accent:#fff;--fs-on-good:#fff;--fs-on-warn:#fff;--fs-on-danger:#fff}
:root[data-palette="forum"]{--fs-bg-base:#fff;--fs-panel2-base:#f0f0f0;--fs-border-base:#ddd;--fs-text:#222;--fs-dim:#3d3d3d;--fs-faint:#4a4a4a;--fs-accent-base:#0073ac;--fs-track:#eee;--fs-good-base:#008000;--fs-warn-base:#717100;--fs-danger-base:#c63c1b}
:root[data-palette="forum"][data-darkmode="true"]{--fs-bg-base:#222;--fs-panel-base:#262626;--fs-panel2-base:#2e2e2e;--fs-border-base:#3a3a3a;--fs-text:#ddd;--fs-dim:#a3a3a3;--fs-faint:#969696;--fs-accent-base:#13a8e3;--fs-accent-soft:color-mix(in srgb,var(--fs-accent) 15%,transparent);--fs-track:#333;--fs-good-base:#1fb65a;--fs-warn-base:#c99259;--fs-danger-base:#eb8066;--fs-on-accent:#222;--fs-on-good:#222;--fs-on-warn:#222;--fs-on-danger:#222;--fs-bar-bg:#002b49}
@supports not (color:color-mix(in srgb,red 50%,transparent)){:root{--fs-accent-soft:transparent;--fs-good-soft:transparent;--fs-warn-soft:transparent;--fs-danger-soft:transparent;--fs-accent-fill:transparent;--fs-good-fill:transparent;--fs-warn-fill:transparent;--fs-danger-fill:transparent;--fs-accent-line:var(--fs-accent);--fs-good-line:var(--fs-good);--fs-warn-line:var(--fs-warn);--fs-danger-line:var(--fs-danger);--fs-accent-line-hi:var(--fs-accent);--fs-good-line-hi:var(--fs-good);--fs-warn-line-hi:var(--fs-warn);--fs-danger-line-hi:var(--fs-danger);--fs-focus-ring:var(--fs-focus-ring-solo);--fs-focus-ring-invalid:0 0 0 2px var(--fs-panel),0 0 0 5px var(--fs-danger);--background-color-medium:var(--fs-panel2);--fs-photo-scrim:var(--fs-scrim);--fs-placeholder:var(--fs-faint);--border-color-high:var(--fs-border);--border-color-low:var(--fs-border);--primary-color-medium:var(--fs-accent);--primary-color-low:var(--fs-dim);--error-color-medium:var(--fs-danger);--error-color-low:var(--fs-dim);--success-color-medium:var(--fs-good);--success-color-low:var(--fs-dim);--warn-color-medium:var(--fs-warn);--warn-color-low:var(--fs-dim)}
@supports not (color:color-mix(in srgb,red 50%,transparent)){:root{--fs-accent-soft:transparent;--fs-good-soft:transparent;--fs-danger-soft:transparent;--fs-accent-fill:transparent;--fs-good-fill:transparent;--fs-warn-fill:transparent;--fs-danger-fill:transparent;--fs-accent-line:var(--fs-accent);--fs-good-line:var(--fs-good);--fs-danger-line:var(--fs-danger);--fs-accent-line-hi:var(--fs-accent);--fs-good-line-hi:var(--fs-good);--fs-danger-line-hi:var(--fs-danger);--fs-focus-ring:var(--fs-focus-ring-solo);--fs-focus-ring-invalid:0 0 0 2px var(--fs-panel),0 0 0 5px var(--fs-danger);--background-color-medium:var(--fs-panel2);--fs-photo-scrim:var(--fs-scrim);--fs-placeholder:var(--fs-faint);--border-color-high:var(--fs-border);--border-color-low:var(--fs-border);--primary-color-medium:var(--fs-accent);--primary-color-low:var(--fs-dim);--error-color-medium:var(--fs-danger);--error-color-low:var(--fs-dim);--success-color-medium:var(--fs-good);--success-color-low:var(--fs-dim);--warn-color-medium:var(--fs-warn);--warn-color-low:var(--fs-dim)}
}
}
@layer base{*,*::before,*::after{scroll-margin-top:var(--fs-bar-h);margin:0;padding:0;border:0;box-sizing:border-box}
@@ -76,7 +76,7 @@ small{font-size:var(--fs-type)}
address{display:block;margin-bottom:var(--fs-space-4)}
code,pre{font-family:var(--fs-font-mono);font-size:var(--fs-type);border-radius:var(--fs-radius-sm)}
code{background-color:var(--fs-panel2);color:var(--fs-text);padding:1px var(--fs-space-1);overflow-wrap:anywhere}
pre{--border-color:color-mix(in srgb,var(--fs-border) 15%,transparent);background-color:var(--fs-panel2);display:block;padding:var(--fs-space-2);margin:0 0 var(--fs-space-4);border:1px solid var(--border-color);border-radius:var(--fs-radius-sm);white-space:pre-wrap;word-wrap:break-word}
pre{background-color:var(--fs-panel2);display:block;padding:var(--fs-space-2);margin:0 0 var(--fs-space-4);border:1px solid color-mix(in srgb,var(--fs-border) 15%,transparent);white-space:pre-wrap;overflow-wrap:break-word}
fieldset{margin-bottom:var(--fs-space-2);padding-top:var(--fs-space-2)}
fieldset legend{display:block;line-height:1;color:var(--fs-text);padding-top:var(--fs-space-5)}
label,input,button{font-family:var(--fs-font-sans);font-size:var(--fs-type);font-weight:var(--fs-weight-normal);line-height:normal}
@@ -104,8 +104,8 @@ input[disabled],button[disabled],select[disabled],textarea[disabled],.cbi-dropdo
.cbi-section-create{display:inline-flex;align-items:center;gap:var(--fs-space-2)}
.cbi-section-create>*{flex:1 1 auto}
.actions{background:var(--fs-panel2);margin-bottom:var(--fs-space-4);padding:var(--fs-card-pad);border-top:var(--fs-hairline);border-radius:0 0 var(--fs-radius-lg) var(--fs-radius-lg);text-align:end}
.actions .secondary-action,.cbi-page-actions .secondary-action{float:right}
:root[dir="rtl"] .actions .secondary-action,:root[dir="rtl"] .cbi-page-actions .secondary-action{float:left}
.actions .secondary-action{float:right}
:root[dir="rtl"] .actions .secondary-action{float:left}
.actions .secondary-action a,.cbi-page-actions .secondary-action a{line-height:var(--fs-control-h)}
.actions .secondary-action a:hover,.cbi-page-actions .secondary-action a:hover{text-decoration:underline}
.cbi-page-actions>form{display:inline;margin:0}
@@ -120,8 +120,8 @@ input[disabled],button[disabled],select[disabled],textarea[disabled],.cbi-dropdo
.table .tr:first-child .th{padding-top:var(--fs-space-2);font-weight:var(--fs-weight-bold);vertical-align:top}
.tr.placeholder{height:calc(3em + var(--fs-space-4))}
.tr.placeholder>.td{position:absolute;inset-inline:0;text-align:center;line-height:3em;padding-block:var(--fs-space-2)}
.tr.drag-over-above,.tr.drag-over-below{border:2px solid var(--fs-accent);border-width:2px 0 0 0}
.tr.drag-over-below{border-width:0 0 2px 0}
.tr.drag-over-above{border-top:2px solid var(--fs-accent)}
.tr.drag-over-below{border-bottom:2px solid var(--fs-accent)}
header{position:sticky;top:0;z-index:var(--fs-z-header);overflow:visible;color:var(--fs-dim);display:flex}
li.menu,.dropdown{position:relative}
.dropdown:hover ul.dropdown-menu{inset-inline-start:0}
@@ -153,12 +153,10 @@ body.modal-overlay-active #modal_overlay{inset-inline:0;opacity:1;visibility:vis
.btn.primary,.cbi-button-action,.cbi-button-apply,.cbi-button-reload,.cbi-button-edit{--on-color:var(--fs-accent)}
.cbi-button-negative,.cbi-section-remove .cbi-button,.cbi-button-reset,.cbi-button-remove{--on-color:var(--fs-danger)}
.cbi-page-actions::after{display:table;content:"";clear:both}
.cbi-page-actions>*{vertical-align:middle}
.cbi-page-actions>:not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset){margin-inline-end:var(--fs-space-1)}
.btn.primary,.cbi-button-action.important,.cbi-page-actions .cbi-button-apply,.cbi-section-actions .cbi-button-edit{--on-color:var(--fs-on-accent)}
.cbi-button-positive.important,.cbi-page-actions .cbi-button-save{--on-color:var(--fs-on-good)}
.btn.primary,.cbi-button-action.important,.cbi-section-actions .cbi-button-edit{--on-color:var(--fs-on-accent)}
.cbi-button-positive.important{--on-color:var(--fs-on-good)}
.cbi-button-negative.important{--on-color:var(--fs-on-danger)}
.cbi-page-actions .cbi-button-apply + .cbi-button-save,.cbi-page-actions .cbi-button-negative + .cbi-button-save{--on-color:var(--fs-good)}
.cbi-title-ref::after{content:"➙"}
.cbi-tooltip-container{cursor:help}
.cbi-tooltip{position:absolute;z-index:var(--fs-z-tooltip);inset-inline-start:-10000px;border-radius:var(--fs-radius-sm);background:var(--fs-panel);white-space:pre-wrap;padding:var(--fs-space-0-5) var(--fs-space-1);opacity:0;transition:opacity var(--fs-dur-fade)}
@@ -223,7 +221,8 @@ th[data-sort-direction="desc"]::after{content:"\a0\25bc"}
.network-status-table .ifacebox-body>span{flex:10 10 auto;height:100%}
.network-status-table .ifacebox-body>div{margin:-.125em;display:flex;flex-wrap:wrap}
#dsl_status_table .ifacebox-body span>strong{display:inline-block;min-width:35%}
.ifacebadge.large,.network-status-table .ifacebox-body .ifacebadge{display:flex;flex:1;padding:var(--fs-space-1);min-width:220px;margin:var(--fs-space-1)}
.ifacebadge.large,.network-status-table .ifacebox-body .ifacebadge{flex:1;padding:var(--fs-space-1);min-width:220px;margin:var(--fs-space-1)}
.network-status-table .ifacebox-body .ifacebadge{display:flex}
.ifacebadge.large{display:inline-flex}
.network-status-table .ifacebox-body .ifacebadge>span{overflow:hidden;text-overflow:ellipsis}
.ifacebadge>*,.ifacebadge.large>*{margin:0 var(--fs-space-1)}
@@ -255,7 +254,7 @@ div.cbi-value var.cbi-tooltip-container,.td.cbi-value-field var.cbi-tooltip-cont
}
.flash{animation:flash var(--fs-dur-fade)}
#view>div.spinning:first-child{display:table;margin:15vh auto}
.spinning::before{--spinner-icon:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");display:inline-block;flex:0 0 auto;vertical-align:middle;margin-inline-end:var(--fs-space-1);width:var(--fs-spin-size);height:var(--fs-spin-size);content:" ";background:var(--on-color,#000);mask:var(--spinner-icon) center/cover no-repeat;-webkit-mask:var(--spinner-icon) center/cover no-repeat;animation:fs-spin 1s linear infinite}
.spinning::before{--spinner-icon:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");display:inline-block;flex:0 0 auto;vertical-align:middle;margin-inline-end:var(--fs-space-1);width:var(--fs-space-5);height:var(--fs-space-5);content:" ";background:var(--on-color,#000);mask:var(--spinner-icon) center/cover no-repeat;-webkit-mask:var(--spinner-icon) center/cover no-repeat;animation:fs-spin 1s linear infinite}
@keyframes fs-spin{to{transform:rotate(360deg)}}
[data-darkmode="true"] .spinning::before{background:var(--on-color,#fff)}
[data-tab-title]{height:0;opacity:0;overflow:hidden}
@@ -358,7 +357,7 @@ body{padding:0;background-color:var(--fs-bg);font-family:var(--fs-font-sans)}
.fs-sidebar ul.nav>li.has-sub.open>ul{display:block}
.fs-sidebar ul.nav ul a{display:block;padding:var(--fs-space-2) var(--fs-space-3);border-radius:var(--fs-radius-sm);color:var(--fs-dim);font-size:var(--fs-type);font-weight:var(--fs-weight)}
.fs-sidebar #indicators{display:flex;flex-wrap:wrap;gap:var(--fs-space-1-5);order:2;margin-inline-start:auto;padding:0}
.fs-themerow{display:flex;align-items:center;justify-content:center;flex:0 0 auto;order:2;margin-inline-start:auto;width:var(--fs-btn-size);height:var(--fs-btn-size);padding:0;border:var(--fs-hairline);border-radius:var(--fs-radius);background:var(--fs-bar-ctl-bg);color:var(--fs-dim);font:inherit;font-weight:var(--fs-weight);font-size:var(--fs-type);line-height:1.1;text-align:start;cursor:pointer;transition:background var(--fs-dur),border-color var(--fs-dur),color var(--fs-dur)}
.fs-themerow{display:flex;align-items:center;justify-content:center;flex:0 0 auto;order:2;margin-inline-start:auto;width:var(--fs-btn-size);height:var(--fs-btn-size);padding:0;border:var(--fs-hairline);border-radius:var(--fs-radius);background:transparent;color:var(--fs-dim);font:inherit;font-weight:var(--fs-weight);font-size:var(--fs-type);line-height:1.1;text-align:start;cursor:pointer;transition:background var(--fs-dur),border-color var(--fs-dur),color var(--fs-dur)}
.fs-sidebar.fs-bar-actrow .fs-brandrow{flex:1 1 100%;max-width:100%}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]{width:var(--fs-btn-size);padding:0;font-size:0;justify-content:center;position:relative}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before{content:"";width:var(--fs-icon-refresh-sz);height:var(--fs-icon-refresh-sz);background:var(--fs-good);-webkit-mask:var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;mask:var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat}
@@ -367,7 +366,7 @@ body{padding:0;background-color:var(--fs-bg);font-family:var(--fs-font-sans)}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after{content:attr(data-fs-badge);font-size:var(--fs-type-xs);font-weight:var(--fs-weight-bold);font-variant-numeric:tabular-nums}
.fs-sidebar #indicators:not(:empty) ~ .fs-themerow{margin-inline-start:0}
.fs-themerow ~ .fs-themerow{margin-inline-start:0}
.fs-logout{flex:0 0 auto;order:2;justify-content:center;width:var(--fs-btn-size);height:var(--fs-btn-size);padding:0;border:var(--fs-hairline);border-radius:var(--fs-radius);background:var(--fs-bar-ctl-bg)}
.fs-logout{flex:0 0 auto;order:2;justify-content:center;width:var(--fs-btn-size);height:var(--fs-btn-size);padding:0;border:var(--fs-hairline);border-radius:var(--fs-radius);background:transparent}
.fs-themerow:hover,.fs-themerow[aria-expanded="true"]{border-color:var(--fs-accent)}
.fs-logout:hover{border-color:var(--fs-danger)}
.fs-themerow .fs-label,.fs-logout .fs-label{display:none}
@@ -434,30 +433,28 @@ body{padding:0;background-color:var(--fs-bg);font-family:var(--fs-font-sans)}
footer{padding:var(--fs-space-3-5) var(--fs-content-pad);border-top:var(--fs-hairline);color:var(--fs-dim);font-size:var(--fs-type);background:var(--fs-bg);justify-content:center;text-align:center;line-height:max(26px,calc(var(--fs-type) * 2))}
footer a{color:var(--fs-dim)}
footer a:hover{color:var(--fs-accent)}
@media (max-width:767px){.fs-content{padding:var(--fs-space-4)}
}
.fs-main>.alert-message{order:1;margin:var(--fs-space-4) auto 0;width:calc(100% - var(--fs-content-pad) * 2);max-width:calc(var(--fs-content-max) - var(--fs-content-pad) * 2)}
.fs-main>.fs-content{order:2}
.fs-main>.fs-footer{order:3}
@media (max-width:767px){.fs-main>.alert-message{margin:var(--fs-space-3) var(--fs-space-4) 0;width:auto}
}
@media (max-width:767px){.fs-main .alert-message{flex-wrap:wrap}
@media (max-width:767px){.fs-content{padding:var(--fs-space-4)}
.fs-main>.alert-message{margin:var(--fs-space-3) var(--fs-space-4) 0;width:auto}
.fs-main .alert-message{flex-wrap:wrap}
.fs-main .alert-message>*{min-width:0;margin-inline-start:0}
.fs-main .alert-message div{flex-wrap:wrap;min-width:0}
.fs-main .alert-message .btn,.fs-main .alert-message button{white-space:normal;max-width:100%}
}
.fs-indicators .label,.fs-indicators [data-indicator],#indicators .label,#indicators [data-indicator]{display:inline-flex;align-items:center;min-height:var(--fs-btn-size);text-transform:none;font-weight:var(--fs-weight);font-size:var(--fs-type);background:var(--fs-bar-ctl-bg);background-image:none;border:var(--fs-hairline);color:var(--fs-dim);border-radius:var(--fs-radius);padding:0 var(--fs-space-3);box-shadow:none}
.fs-indicators .label,.fs-indicators [data-indicator],#indicators .label,#indicators [data-indicator]{display:inline-flex;align-items:center;min-height:var(--fs-btn-size);text-transform:none;font-weight:var(--fs-weight);font-size:var(--fs-type);background:transparent;border:var(--fs-hairline);color:var(--fs-dim);border-radius:var(--fs-radius);padding:0 var(--fs-space-3);box-shadow:none}
.fs-indicators [data-indicator][data-style="active"],#indicators [data-indicator][data-style="active"]{color:var(--fs-good)}
.fs-indicators [data-indicator][data-clickable],#indicators [data-indicator][data-clickable]{cursor:pointer}
.fs-indicators [data-indicator][data-clickable]:focus-visible,#indicators [data-indicator][data-clickable]:focus-visible{outline:none;border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring)}
.cbi-progressbar{position:relative;height:10px;background:var(--fs-track);border:0;border-radius:var(--fs-radius-pill);overflow:visible;min-width:120px;margin:0}
.cbi-progressbar{position:relative;height:10px;background:var(--fs-track);border:0;border-radius:var(--fs-radius-pill);overflow:visible;min-width:120px;margin:0;margin-block-start:calc(var(--fs-type-xs) * var(--fs-leading))}
.cbi-progressbar>div{height:100%;background:var(--fs-accent);border-radius:var(--fs-radius-pill);transition:width var(--fs-dur-fill)}
.cbi-progressbar[data-fs-level="warn"]>div{background:var(--fs-warn)}
.cbi-progressbar[data-fs-level="danger"]>div{background:var(--fs-danger)}
.cbi-progressbar::after{content:attr(title);position:absolute;inset-inline:auto 0;bottom:calc(100% + var(--fs-space-1));top:auto;font-family:var(--fs-font-mono);font-size:var(--fs-type-xs);font-weight:var(--fs-weight-normal);color:var(--fs-dim);white-space:nowrap;text-shadow:none}
.cbi-value-field>.cbi-progressbar{margin-inline-end:var(--fs-meter-value);--fs-meter-value:calc(var(--fs-space-10) * 2)}
.cbi-value-field>.cbi-progressbar{margin-inline-end:var(--fs-meter-value);margin-block-start:0;--fs-meter-value:calc(var(--fs-space-10) * 2)}
.cbi-value-field>.cbi-progressbar::after{inset-inline:calc(100% + var(--fs-space-2)) auto;bottom:auto;top:50%;transform:translateY(-50%)}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar{margin-inline-end:var(--fs-meter-value);--fs-meter-value:var(--fs-space-10);min-width:60px}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar{margin-inline-end:var(--fs-meter-value);margin-block-start:0;--fs-meter-value:var(--fs-space-10);min-width:60px}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after{inset-inline:calc(100% + var(--fs-space-2)) auto;bottom:auto;top:50%;transform:translateY(-50%)}
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar{margin-inline-end:0;margin-block-start:calc(var(--fs-type-xs) * var(--fs-leading))}
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar::after{inset-inline:auto 0;bottom:calc(100% + var(--fs-space-1));top:auto;transform:none}
@@ -490,17 +487,17 @@ legend{font-size:var(--fs-type-lg);font-weight:var(--fs-weight-bold);color:var(-
.cbi-section .table:is([id],.fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td{background:var(--fs-panel2)}
.cbi-section .table:is([id],.fs-dt) .tr.table-titles .th{border-bottom-color:var(--fs-border)}
.cbi-section .table[id],#view .table[id],.cbi-section .table.fs-dt,.cbi-section table.fs-dt,table.fs-dt{border-collapse:separate;border-spacing:0;border:var(--fs-hairline);border-radius:var(--fs-radius-lg);overflow:visible}
.cbi-section .table[id] .tr.table-titles .th:first-child,#view .table[id] .tr.table-titles .th:first-child,table.fs-dt .tr.table-titles .th:first-child{border-top-left-radius:var(--fs-radius-lg)}
.cbi-section .table[id] .tr.table-titles .th:last-child,#view .table[id] .tr.table-titles .th:last-child,table.fs-dt .tr.table-titles .th:last-child{border-top-right-radius:var(--fs-radius-lg)}
.cbi-section .table[id] :is(.tr.table-titles,.thead) .th:first-child,#view .table[id] :is(.tr.table-titles,.thead) .th:first-child,table.fs-dt :is(.tr.table-titles,.thead) .th:first-child{border-top-left-radius:var(--fs-radius-lg)}
.cbi-section .table[id] :is(.tr.table-titles,.thead) .th:last-child,#view .table[id] :is(.tr.table-titles,.thead) .th:last-child,table.fs-dt :is(.tr.table-titles,.thead) .th:last-child{border-top-right-radius:var(--fs-radius-lg)}
.cbi-section .table[id] .tr:last-child .td:first-child,#view .table[id] .tr:last-child .td:first-child,table.fs-dt .tr:last-child .td:first-child{border-bottom-left-radius:var(--fs-radius-lg)}
.cbi-section .table[id] .tr:last-child .td:last-child,#view .table[id] .tr:last-child .td:last-child,table.fs-dt .tr:last-child .td:last-child{border-bottom-right-radius:var(--fs-radius-lg)}
.cbi-section .table[id] .tr.placeholder .td,#view .table[id] .tr.placeholder .td,table.fs-dt .tr.placeholder .td{padding-block:var(--fs-space-2);text-align:start;color:var(--fs-dim);font-family:var(--fs-font-sans);border-radius:0 0 var(--fs-radius-lg) var(--fs-radius-lg)}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child{color:var(--fs-dim);font-weight:var(--fs-weight);width:40%;white-space:nowrap}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child){font-family:var(--fs-font-mono);color:var(--fs-text);text-align:end}
@container fs-content (max-width:560px){.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr{display:block;padding:var(--fs-space-1) 0 var(--fs-space-1-5);border:0;border-bottom:var(--fs-hairline)}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr:last-child{border-bottom:0}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child{display:block;width:auto;white-space:normal;padding:0;border:0;font-size:var(--fs-type-xs);font-weight:var(--fs-eyebrow-weight);letter-spacing:var(--fs-eyebrow-tracking);color:var(--fs-eyebrow-color);text-transform:uppercase}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child){display:block;width:auto;text-align:start;padding:0;border:0}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child{color:var(--fs-dim);font-weight:var(--fs-weight);width:40%;white-space:nowrap}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child){font-family:var(--fs-font-mono);color:var(--fs-text);text-align:end}
@container fs-content (max-width:560px){.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr{display:block;padding:var(--fs-space-1) 0 var(--fs-space-1-5);border:0;border-bottom:var(--fs-hairline)}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child{border-bottom:0}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child{display:block;width:auto;white-space:normal;padding:0;border:0;font-size:var(--fs-type-xs);font-weight:var(--fs-eyebrow-weight);letter-spacing:var(--fs-eyebrow-tracking);color:var(--fs-eyebrow-color);text-transform:uppercase}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child){display:block;width:auto;text-align:start;padding:0;border:0}
}
@media (max-width:767px){.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr{display:flex;flex-wrap:wrap}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .td{flex:1 1 50%;min-width:0;width:auto}
@@ -519,6 +516,7 @@ legend{font-size:var(--fs-type-lg);font-weight:var(--fs-weight-bold);color:var(-
.table{border:var(--fs-hairline);border-radius:var(--fs-radius-lg);overflow:hidden;background:var(--fs-panel)}
.table .tr.table-titles>.th,.table .tr.cbi-section-table-titles>.th,.table .tr.cbi-section-table-titles::before{border-bottom:var(--fs-hairline)}
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child)>.td,.table .tr.cbi-section-table-row:not(:last-child)::before{border-bottom:var(--fs-hairline)}
:is(#view,#modal_overlay) .fs-dt .td{top:auto;bottom:auto}
.table .tr.table-titles,.table .tr.cbi-section-table-titles{background:transparent}
.table .tr.table-titles .th,.table .tr.cbi-section-table-titles .th,.table .tr.cbi-section-table-titles::before{color:var(--fs-eyebrow-color);font-size:var(--fs-type-xs);font-weight:var(--fs-eyebrow-weight);letter-spacing:var(--fs-eyebrow-tracking);background:transparent}
.table.cbi-section-table .td.cbi-section-table-titles,.table.cbi-section-table .tr.cbi-section-table-row::before{font-family:var(--fs-font-sans);font-weight:var(--fs-weight-bold);color:var(--fs-text)}
@@ -625,7 +623,7 @@ code,pre{font-weight:var(--fs-weight-normal)}
.zonebadge-empty{border-color:var(--fs-border);color:var(--fs-dim)}
.cbi-map{container-type:inline-size;container-name:fs-content}
#view{container-type:inline-size;container-name:fs-view fs-content}
.modal{container-type:inline-size;container-name:fs-view fs-content}
.modal{container-type:inline-size;container-name:fs-view fs-content;box-shadow:var(--fs-shadow-pop)}
.pager.center{display:flex;flex-direction:row;flex-wrap:nowrap;align-items:center;justify-content:center;gap:var(--fs-space-2-5);padding:var(--fs-space-3-5) 0}
.pager .text{flex:0 0 auto;font-size:var(--fs-type);color:var(--fs-dim)}
h1,h2,h3,h4,h5,h6{font-weight:var(--fs-weight-bold);color:var(--fs-text);line-height:var(--fs-leading-tight)}
@@ -676,7 +674,7 @@ h6{font-size:var(--fs-type-xs);color:var(--fs-dim);text-transform:uppercase}
input[type="file"]::file-selector-button{margin-inline-end:var(--fs-space-3);padding:var(--fs-space-2) var(--fs-space-3-5);font-family:var(--fs-font-sans);font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-text);background:var(--fs-panel2);border:var(--fs-hairline);border-radius:var(--fs-radius-sm);cursor:pointer}
input[type="file"]::file-selector-button:hover{border-color:var(--fs-accent);color:var(--fs-accent)}
.cbi-section-actions .cbi-button,.cbi-section-actions .btn,.cbi-section-table .cbi-button{min-height:var(--fs-ctl-h-sm);padding:0 var(--fs-space-3);font-size:var(--fs-type);flex-basis:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.td .cbi-button,.cbi-section-actions .cbi-button,.cbi-section-table .cbi-button{background:transparent;background-image:none;border:var(--fs-hairline);color:var(--fs-dim)}
.td .cbi-button,.cbi-section-actions .cbi-button,.cbi-section-table .cbi-button{background:transparent;border:var(--fs-hairline);color:var(--fs-dim)}
.td .cbi-button:hover{border-color:var(--fs-dim);color:var(--fs-text)}
.td .cbi-button-action,.td .cbi-button-edit,.td .cbi-button-apply,.cbi-section-actions .cbi-button-edit,.cbi-section-actions .cbi-button-action{color:var(--fs-accent);border-color:var(--fs-accent-line);background:transparent}
.td .cbi-button-action:hover,.td .cbi-button-edit:hover,.td .cbi-button-apply:hover,.cbi-section-actions .cbi-button-action:hover{background:var(--fs-accent-soft);color:var(--fs-text);border-color:var(--fs-accent-line-hi)}
@@ -684,8 +682,8 @@ input[type="file"]::file-selector-button:hover{border-color:var(--fs-accent);col
.td .cbi-button-positive:hover,.cbi-section-actions .cbi-button-positive:hover{background:var(--fs-good-soft);color:var(--fs-text);border-color:var(--fs-good-line-hi)}
.td .cbi-button-negative,.td .cbi-button-remove,.cbi-section-actions .cbi-button-remove,.cbi-section-actions .cbi-button-negative{color:var(--fs-danger);border-color:var(--fs-danger-line);background:transparent}
.td .cbi-button-negative:hover,.td .cbi-button-remove:hover,.cbi-section-actions .cbi-button-negative:hover{background:var(--fs-danger-soft);color:var(--fs-text);border-color:var(--fs-danger-line-hi)}
.cbi-page-actions .cbi-button-apply,.cbi-page-actions .cbi-dropdown .cbi-button-apply{background:var(--fs-accent);background-image:none;border:1px solid transparent;color:var(--fs-on-accent);--on-color:var(--fs-on-accent);text-shadow:none}
.cbi-page-actions .cbi-button-save,.cbi-page-actions .cbi-button-apply + .cbi-button-save,.cbi-page-actions .cbi-button-negative + .cbi-button-save{background:var(--fs-accent-soft);background-image:none;border:1px solid var(--fs-accent-line);color:var(--fs-text);--on-color:var(--fs-on-accent);text-shadow:none}
.cbi-page-actions .cbi-button-apply,.cbi-page-actions .cbi-dropdown .cbi-button-apply{background:var(--fs-accent);border:1px solid transparent;color:var(--fs-on-accent);--on-color:var(--fs-on-accent);text-shadow:none}
.cbi-page-actions .cbi-button-save,.cbi-page-actions .cbi-button-apply + .cbi-button-save,.cbi-page-actions .cbi-button-negative + .cbi-button-save{background:var(--fs-accent-soft);border:1px solid var(--fs-accent-line);color:var(--fs-text);--on-color:var(--fs-on-accent);text-shadow:none}
.cbi-page-actions .cbi-button-save:hover{background:var(--fs-accent-fill);border-color:var(--fs-accent-line-hi);filter:none}
.cbi-dynlist>.add-item>button,.cbi-dynlist>.add-item>.cbi-button-add{flex:0 0 auto;width:auto;min-width:var(--fs-ctl-h);min-height:var(--fs-ctl-h-sm);padding:0 var(--fs-space-3);margin:0;border:0;border-inline-start:var(--fs-hairline);border-radius:0;background:transparent;color:var(--fs-dim)}
.cbi-dynlist>.add-item>button:hover,.cbi-dynlist>.add-item>.cbi-button-add:hover{color:var(--fs-accent)}
@@ -720,21 +718,21 @@ div.cbi-value var,.td.cbi-value-field var{color:var(--fs-accent)}
.cbi-filebrowser .upload>div>input{width:100%}
.btn>input[type="checkbox"],.btn>input[type="radio"]{margin-inline-end:.5em}
.cbi-checkbox{display:inline-flex;align-items:center;gap:var(--fs-space-2);overflow-wrap:anywhere}
.cbi-checkbox>input[type="checkbox"]{position:absolute;opacity:0;width:0;height:0;margin:0}
.cbi-checkbox>label[for]{--sw-w:calc(40px * var(--fs-density-box));--sw-h:calc(22px * var(--fs-density-box));--sw-knob:calc(16px * var(--fs-density-box));--sw-pad:calc(2px * var(--fs-density-box));display:inline-block;position:relative;margin:0;flex-shrink:0;width:var(--sw-w);height:var(--sw-h);border-radius:var(--fs-radius-pill);background:var(--fs-track);border:var(--fs-hairline);cursor:pointer;transition:background var(--fs-dur),border-color var(--fs-dur);vertical-align:middle}
.cbi-checkbox>label[for]::before{content:"";position:absolute;inset:-4px -2px}
.cbi-checkbox>label[for]::after{content:"";position:absolute;top:var(--sw-pad);inset-inline-start:var(--sw-pad);width:var(--sw-knob);height:var(--sw-knob);border-radius:50%;background:var(--fs-dim);transform:translateX(0);transition:transform var(--fs-dur-move),background var(--fs-dur)}
.cbi-checkbox>input[type="checkbox"]:checked + label{background:var(--fs-accent);border-color:transparent}
.cbi-checkbox>input[type="checkbox"]:checked + label::after{transform:translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));background:var(--fs-on-accent)}
.cbi-checkbox>input[type="checkbox"]:focus-visible + label{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring-solo)}
.cbi-checkbox>input[type="checkbox"]:disabled + label{opacity:var(--fs-disabled-opacity);cursor:not-allowed}
.cbi-checkbox>label[for]{position:absolute;opacity:0;width:0;height:0;margin:0}
.cbi-checkbox>input[type="checkbox"]{--sw-w:calc(40px * var(--fs-density-box));--sw-h:calc(22px * var(--fs-density-box));--sw-knob:calc(16px * var(--fs-density-box));--sw-pad:calc(2px * var(--fs-density-box));display:inline-block;position:relative;margin:0;flex-shrink:0;width:var(--sw-w);height:var(--sw-h);border-radius:var(--fs-radius-pill);background:var(--fs-track);border:var(--fs-hairline);cursor:pointer;transition:background var(--fs-dur),border-color var(--fs-dur);vertical-align:middle}
.cbi-checkbox>input[type="checkbox"]::before{inset:-4px -2px}
.cbi-checkbox>input[type="checkbox"]::after{top:var(--sw-pad);inset-inline-start:var(--sw-pad);width:var(--sw-knob);height:var(--sw-knob);border-radius:50%;background:var(--fs-dim);transform:translateX(0);transition:transform var(--fs-dur-move),background var(--fs-dur)}
.cbi-checkbox>input[type="checkbox"]:checked{background:var(--fs-accent);border-color:transparent}
.cbi-checkbox>input[type="checkbox"]:checked::after{transform:translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));background:var(--fs-on-accent)}
.cbi-checkbox>input[type="checkbox"]:focus-visible{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring-solo)}
.cbi-checkbox>input[type="checkbox"]:disabled{opacity:var(--fs-disabled-opacity);cursor:not-allowed}
input[type="checkbox"],input[type="radio"]{--bd-color:var(--fs-border);--fg-color:var(--fs-text);--box:calc(16px * var(--fs-density-box));--mark-inset:3px;--hit:max(24px,var(--box));appearance:none;width:var(--hit);height:var(--hit);margin:calc((var(--box) - var(--hit)) / 2);color:var(--fg-color);position:relative;display:inline-block;cursor:pointer;background:none;border:none}
input[type="checkbox"]::before,input[type="checkbox"]::after,input[type="radio"]::before,input[type="radio"]::after{position:absolute;content:""}
input[type="checkbox"]::before,input[type="radio"]::before{inset:0;margin:auto;width:var(--box);height:var(--box);background:linear-gradient(var(--fs-panel),var(--fs-panel2));border:1px solid var(--bd-color);transition:var(--fs-field-transition)}
input[type="checkbox"]:not(:where(.cbi-checkbox>input))::before,input[type="radio"]::before{inset:0;margin:auto;width:var(--box);height:var(--box);background:linear-gradient(var(--fs-panel),var(--fs-panel2));border:1px solid var(--bd-color);transition:var(--fs-field-transition)}
input[type="checkbox"]::before{border-radius:2px}
input[type="radio"],input[type="radio"]::before{border-radius:50%}
input[type="checkbox"]::after,input[type="radio"]::after{inset:0;margin:auto;width:calc(var(--box) - var(--mark-inset) * 2);height:calc(var(--box) - var(--mark-inset) * 2)}
input[type="checkbox"]:checked::after,input[type="radio"]:checked::after{--checkmark-icon:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");-webkit-mask:var(--checkmark-icon) center/cover no-repeat;mask:var(--checkmark-icon) center/cover no-repeat;background:var(--fg-color)}
input[type="checkbox"]:not(:where(.cbi-checkbox>input))::after,input[type="radio"]::after{inset:0;margin:auto;width:calc(var(--box) - var(--mark-inset) * 2);height:calc(var(--box) - var(--mark-inset) * 2)}
input[type="checkbox"]:not(:where(.cbi-checkbox>input)):checked::after,input[type="radio"]:checked::after{--checkmark-icon:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");-webkit-mask:var(--checkmark-icon) center/cover no-repeat;mask:var(--checkmark-icon) center/cover no-repeat;background:var(--fg-color)}
input[type="radio"]:checked:after{--checkmark-icon:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>")}
input[type="checkbox"].cbi-input-invalid,input[type="radio"].cbi-input-invalid{--bd-color:var(--fs-danger);--fg-color:var(--fs-danger)}
input{display:inline-block;width:210px;height:var(--fs-control-h);padding:var(--fs-space-1) var(--fs-space-2);background:var(--fs-panel);color:var(--fs-text);border:var(--fs-hairline);border-radius:var(--fs-radius)}
@@ -753,7 +751,7 @@ select option{background:var(--fs-panel);color:var(--fs-text)}
}
.cbi-select:not(select){display:inline-block;width:210px;height:var(--fs-ctl-h);color:var(--fs-text);font-size:var(--fs-type);border:var(--fs-hairline);border-radius:var(--fs-radius);padding:0;position:relative;background-color:var(--fs-panel2);background-image:var(--fs-select-chevron);background-repeat:no-repeat;background-size:6px 6px;background-position:right calc(var(--fs-space-3) - 1px) center,right calc(var(--fs-space-3) + 5px) center;transition:var(--fs-field-transition)}
.cbi-select[disabled]:not(select){opacity:var(--fs-disabled-opacity);pointer-events:none}
.cbi-select select,.cbi-select select:focus{appearance:none;outline:none;border:none;background:transparent;background-image:none;box-shadow:none;height:100%;min-height:0;width:100%;padding:0 var(--fs-space-8) 0 var(--fs-space-3);font-size:var(--fs-type);line-height:normal;cursor:pointer}
.cbi-select select,.cbi-select select:focus{appearance:none;outline:none;border:none;background:transparent;box-shadow:none;height:100%;min-height:0;width:100%;padding:0 var(--fs-space-8) 0 var(--fs-space-3);font-size:var(--fs-type);line-height:normal;cursor:pointer}
.spinning,.cbi-value em,em.spinning{color:var(--fs-dim)}
.cbi-input-text,.cbi-input-password,.cbi-input-textarea,.cbi-section-create-name,.cbi-value-field input.create-item-input,input[type="text"],input[type="password"],input[type="number"],input[type="email"],input[type="url"],input[type="search"],input[type="tel"],input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"],input[type="week"],input[type="color"],textarea{background:var(--fs-panel2);border:var(--fs-hairline);border-radius:var(--fs-radius);color:var(--fs-text);padding:var(--fs-space-2) var(--fs-space-3);min-height:var(--fs-ctl-h);box-shadow:none;transition:var(--fs-field-transition)}
textarea{min-height:84px;line-height:var(--fs-leading);width:210px;max-width:100%;font-size:var(--fs-type)}
@@ -765,8 +763,8 @@ textarea::-webkit-scrollbar-corner{background:transparent}
textarea::-webkit-resizer{background:transparent;border-right:2px solid var(--fs-accent);border-bottom:2px solid var(--fs-accent);border-bottom-right-radius:var(--fs-radius);margin:calc(16px - var(--fs-radius)) 0 0 calc(16px - var(--fs-radius))}
:root[dir="rtl"] textarea::-webkit-resizer{border-right:0;border-left:2px solid var(--fs-accent);border-bottom-right-radius:0;border-bottom-left-radius:var(--fs-radius);margin:calc(16px - var(--fs-radius)) calc(16px - var(--fs-radius)) 0 0}
.cbi-input-text:hover,.cbi-input-password:hover,.cbi-input-textarea:hover,input[type="text"]:hover,input[type="password"]:hover,input[type="number"]:hover,input[type="email"]:hover,input[type="url"]:hover,input[type="search"]:hover,textarea:hover{border-color:var(--fs-dim)}
.cbi-input-text:focus,.cbi-input-password:focus,.cbi-input-textarea:focus,.cbi-section-create-name:focus,input:focus,input[type="text"]:focus,input[type="password"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="url"]:focus,input[type="search"]:focus,input[type="tel"]:focus,input[type="date"]:focus,input[type="time"]:focus,textarea:focus,select:focus,.cbi-dynlist>.item:focus{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring);outline:none}
input[type="checkbox"]:focus::before,input[type="radio"]:focus::before,.cbi-select.focus,.cbi-dynlist>.add-item:focus-within,.control-group:is(:has(>.cbi-input-password)):focus-within{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring)}
.cbi-input-text:focus,.cbi-input-password:focus,.cbi-input-textarea:focus,.cbi-section-create-name:focus,input:focus:not(:where(.cbi-checkbox>input)),input[type="text"]:focus,input[type="password"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="url"]:focus,input[type="search"]:focus,input[type="tel"]:focus,input[type="date"]:focus,input[type="time"]:focus,textarea:focus,select:focus,.cbi-dynlist>.item:focus{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring);outline:none}
input[type="checkbox"]:not(:where(.cbi-checkbox>input)):focus::before,input[type="radio"]:focus::before,.cbi-select.focus,.cbi-dynlist>.add-item:focus-within,.control-group:is(:has(>.cbi-input-password)):focus-within{border-color:var(--fs-accent);box-shadow:var(--fs-focus-ring)}
input[type="checkbox"].cbi-input-invalid:focus::before,input[type="radio"].cbi-input-invalid:focus::before,.cbi-select.cbi-input-invalid.focus,.cbi-input-invalid,input.cbi-input-invalid,textarea.cbi-input-invalid,.cbi-value-error input,.cbi-value-error textarea,.cbi-input-invalid:focus{border-color:var(--fs-danger);box-shadow:var(--fs-focus-ring-invalid)}
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),.cbi-input-invalid.cbi-dropdown:not([open])>ul>li{border-color:var(--fs-danger)}
:is(input,select,textarea)[readonly][readonly]{border-color:color-mix(in srgb,var(--fs-border) calc(var(--fs-disabled-opacity) * 100%),transparent);pointer-events:auto;cursor:auto}
@@ -793,9 +791,9 @@ input[type="range"]:focus::-moz-range-track{box-shadow:var(--fs-focus-ring-solo)
.cbi-range-slider-calc,.cbi-range-slider-calc-units{color:var(--fs-dim);font-size:var(--fs-type)}
.dropdown-menu{background-color:var(--fs-panel);border-color:var(--fs-border)}
.dropdown-menu a{color:var(--fs-text);text-shadow:none}
.dropdown-menu a:hover,.dropdown-menu a:focus-visible{background:var(--fs-panel2);background-image:none;color:var(--fs-accent)}
.dropdown-menu a:hover,.dropdown-menu a:focus-visible{background:var(--fs-panel2);color:var(--fs-accent)}
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button){border:0;border-radius:var(--fs-radius);background:transparent}
.cbi-page-actions .cbi-dropdown.cbi-button-apply,.cbi-page-actions .cbi-dropdown>.cbi-button{background:var(--fs-accent);background-image:none;border:0;color:var(--fs-on-accent)}
.cbi-page-actions .cbi-dropdown.cbi-button-apply,.cbi-page-actions .cbi-dropdown>.cbi-button{background:var(--fs-accent);border:0;color:var(--fs-on-accent)}
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,.cbi-page-actions .cbi-dropdown>.cbi-button:hover{filter:var(--fs-hover-lift)}
.cbi-dropdown{display:inline-flex;cursor:pointer;height:auto;position:relative;padding:0}
.cbi-dropdown img{max-height:1.6em;width:auto}
@@ -840,6 +838,7 @@ input[type="range"]:focus::-moz-range-track{box-shadow:var(--fs-focus-ring-solo)
.cbi-dropdown[open]{z-index:var(--fs-z-dropdown)}
.cbi-dropdown[open]>ul.dropdown{display:block;position:absolute;z-index:var(--fs-z-dropdown);transition:max-height var(--fs-dur-move);overflow-y:auto;background:var(--fs-panel);border:var(--fs-hairline);border-radius:var(--fs-radius-lg);box-shadow:var(--fs-shadow-pop);padding:var(--fs-space-1-5);margin-top:var(--fs-space-1-5)}
.cbi-dropdown[open]>ul.dropdown>li{border-radius:var(--fs-radius-sm);padding:var(--fs-space-2) var(--fs-space-3);border-inline-start:0;color:var(--fs-text);position:relative;white-space:normal;overflow-wrap:anywhere;border-bottom:0}
.cbi-dropdown[open]>ul.dropdown>li .zonebadge{white-space:normal;overflow-wrap:anywhere}
.cbi-dropdown[open]>ul.dropdown>li:not(:last-child)::after{content:"";position:absolute;inset-inline:11px;bottom:0;height:1px;background:var(--fs-border)}
.cbi-dropdown[open]>ul.dropdown>li:hover,.cbi-dropdown[open]>ul.dropdown>li.focus{background:var(--fs-panel2);color:var(--fs-accent)}
.cbi-dropdown[open]>ul.dropdown>li[selected]{background:var(--fs-panel2);color:var(--fs-accent);box-shadow:var(--fs-row-rail)}
@@ -864,15 +863,14 @@ input[type="range"]:focus::-moz-range-track{box-shadow:var(--fs-focus-ring-solo)
.table.cbi-section-table .td .zone-forwards{width:100%}
}
.table:has(.cbi-dropdown),.table:has(.cbi-dynlist){overflow:visible;border-collapse:separate;border-spacing:0}
.modal{box-shadow:var(--fs-shadow-pop)}
.modal>*{line-height:normal;margin-bottom:.5em;max-width:100%}
.modal>.alert-message{margin-bottom:var(--fs-space-2)}
.modal>pre,.modal>textarea{white-space:pre-wrap;overflow:auto;width:100%}
.modal:has(.cbi-map){max-width:min(1100px,94vw)}
#modal_overlay .modal label.btn{white-space:normal;overflow-wrap:anywhere;min-width:0}
#modal_overlay .modal li{overflow-wrap:anywhere}
#modal_overlay .uci-dialog div>ins,#modal_overlay .uci-dialog div>del,#modal_overlay .uci-dialog div>var{border-radius:var(--fs-radius-sm)}
#modal_overlay .uci-change-legend-label>ins,#modal_overlay .uci-change-legend-label>del,#modal_overlay .uci-change-legend-label>var{border-radius:var(--fs-radius-sm);overflow:hidden}
#modal_overlay .uci-dialog div>ins,#modal_overlay .uci-dialog div>del,#modal_overlay .uci-dialog div>var,#modal_overlay .uci-change-legend-label>ins,#modal_overlay .uci-change-legend-label>del,#modal_overlay .uci-change-legend-label>var{border-radius:var(--fs-radius-sm)}
#modal_overlay .uci-change-legend-label>ins,#modal_overlay .uci-change-legend-label>del,#modal_overlay .uci-change-legend-label>var{overflow:hidden}
#modal_overlay .uci-dialog var>ins,#modal_overlay .uci-dialog var>del{border-radius:max(0px,calc(var(--fs-radius-sm) - 3px))}
.fs-search-ov{position:fixed;inset:0;z-index:var(--fs-z-popover);display:flex;justify-content:center;align-items:flex-start;padding:max(8vh,var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);background:var(--fs-scrim);animation:fs-fade var(--fs-dur-fade)}
.fs-search-box{display:flex;flex-direction:column;min-height:0;width:100%;max-width:560px;max-height:min(70dvh,560px);background:var(--fs-panel);border:var(--fs-hairline);border-radius:var(--fs-radius-lg);box-shadow:var(--fs-shadow-pop);overflow:hidden}
@@ -913,19 +911,21 @@ input[type="range"]:focus::-moz-range-track{box-shadow:var(--fs-focus-ring-solo)
input,textarea,select,.cbi-dropdown>ul>li{font-size:var(--fs-type-lg)}
}
.cbi-dropdown[open]>ul.dropdown{inset-inline-start:0 !important;inset-inline-end:auto !important;min-width:100%;width:max-content;max-width:min(92vw,420px)}
@media (max-width:767px){.cbi-dropdown[open]>ul.dropdown{width:auto}
}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}
}
@media (prefers-reduced-transparency:reduce){:root{--fs-photo-scrim:var(--fs-bg)}
}
@media (prefers-contrast:more){:root{--fs-border:color-mix(in srgb,var(--fs-text) 45%,var(--fs-bg));--fs-dim:color-mix(in srgb,var(--fs-text) 85%,var(--fs-bg));--fs-faint:color-mix(in srgb,var(--fs-text) 82%,var(--fs-bg));--fs-focus-ring:0 0 0 3px color-mix(in srgb,var(--fs-accent) 60%,transparent);--fs-accent-line:var(--fs-accent);--fs-accent-line-hi:var(--fs-accent);--fs-good-line:var(--fs-good);--fs-good-line-hi:var(--fs-good);--fs-warn-line:var(--fs-warn);--fs-warn-line-hi:var(--fs-warn);--fs-danger-line:var(--fs-danger);--fs-danger-line-hi:var(--fs-danger);--fs-placeholder-mix:62%}
@media (prefers-contrast:more){:root{--fs-border:color-mix(in srgb,var(--fs-text) 45%,var(--fs-bg));--fs-dim:color-mix(in srgb,var(--fs-text) 85%,var(--fs-bg));--fs-faint:color-mix(in srgb,var(--fs-text) 82%,var(--fs-bg));--fs-focus-ring:0 0 0 3px color-mix(in srgb,var(--fs-accent) 60%,transparent);--fs-accent-line:var(--fs-accent);--fs-accent-line-hi:var(--fs-accent);--fs-good-line:var(--fs-good);--fs-good-line-hi:var(--fs-good);--fs-danger-line:var(--fs-danger);--fs-danger-line-hi:var(--fs-danger);--fs-placeholder-mix:62%}
:root[data-darkmode="true"]{--fs-placeholder-mix:80%}
}
@media (forced-colors:active){#tabmenu ul.tabs>li.active>a,ul.cbi-tabmenu.mode>li.cbi-tab>a,.tabs>li.active>a,.cbi-tabmenu>li.cbi-tab>a,.cbi-dropdown>ul>li[selected],.ifacebox .ifacebox-head.active,.cbi-page-actions .cbi-button-apply{outline:2px solid Highlight;outline-offset:-2px;color:HighlightText;background:Highlight;forced-color-adjust:none}
#topmenu li.active>a{border-inline-start:3px solid Highlight;padding-inline-start:calc(var(--fs-space-3) - 3px)}
.cbi-checkbox>label[for]{border:1px solid ButtonBorder}
.cbi-checkbox>input[type="checkbox"]:checked + label{background:Highlight;forced-color-adjust:none}
.cbi-checkbox>label[for]::after{background:ButtonBorder}
.cbi-checkbox>input[type="checkbox"]:checked + label::after{background:HighlightText}
.cbi-checkbox>input[type="checkbox"]{border:1px solid ButtonBorder}
.cbi-checkbox>input[type="checkbox"]:checked{background:Highlight;forced-color-adjust:none}
.cbi-checkbox>input[type="checkbox"]::after{background:ButtonBorder}
.cbi-checkbox>input[type="checkbox"]:checked::after{background:HighlightText}
.fs-railtoggle,.fs-themerow{border-color:ButtonBorder}
:focus-visible{outline:2px solid Highlight;outline-offset:1px}
}
@@ -956,7 +956,7 @@ input,textarea,select,.cbi-dropdown>ul>li{font-size:var(--fs-type-lg)}
.cbi-title .label[data-indicator="poll-status"]{font-size:0;width:16px;height:16px;padding:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:0;color:var(--fs-faint)}
.cbi-title .label[data-indicator="poll-status"]::after{content:"";width:7px;height:7px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;border-radius:1px;transform:rotate(45deg);margin-top:-2px;transition:transform var(--fs-dur-move)}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after{transform:rotate(-135deg);margin-top:var(--fs-space-0-5)}
:is(.cbi-title h3,.cbi-section>h3)[role="button"]{cursor:pointer;border-radius:var(--fs-radius-sm)}
:is(.cbi-title h3,.cbi-section>h3)[role="button"]{cursor:pointer;-webkit-user-select:none;user-select:none;border-radius:var(--fs-radius-sm)}
:is(.cbi-title h3,.cbi-section>h3)[role="button"]:focus-visible{outline:none;box-shadow:var(--fs-focus-ring)}
:where(#view)[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty{display:none}
.fs-content[data-page="admin-status-overview"] h2[name="content"]{display:none}
@@ -968,7 +968,7 @@ input,textarea,select,.cbi-dropdown>ul>li{font-size:var(--fs-type-lg)}
@container fs-view (max-width:800px){.fs-ovl{grid-template-columns:1fr}
.fs-ovl>.fs-ovl-sys,.fs-ovl>.fs-ovl-mem,.fs-ovl>.fs-ovl-sto{grid-column:1;grid-row:auto}
}
:where(#view)[data-page="admin-status-overview"] div:has(>.ifacebox img[src*="/port_"]){gap:var(--fs-space-2);--fs-port-min:calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2);grid-template-columns:repeat(auto-fit,minmax(var(--fs-port-min),1fr)) !important;margin-bottom:0 !important}
:where(#view)[data-page="admin-status-overview"] div:has(>.ifacebox img[src*="/port_"]){gap:var(--fs-space-2);grid-template-columns:repeat(auto-fit,minmax(calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2),1fr)) !important;margin-bottom:0 !important}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]){margin:0 !important;min-width:0 !important;max-width:200px !important;width:auto !important;justify-self:stretch;align-self:stretch;text-align:start;background:var(--fs-panel2);border:var(--fs-hairline);border-radius:var(--fs-radius-lg);padding:var(--fs-space-1-5) var(--fs-space-3);display:flex;flex-direction:row;flex-wrap:wrap;column-gap:var(--fs-space-2);row-gap:0;align-items:baseline}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img{display:none}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head{border-bottom:0;border-radius:0}
@@ -980,22 +980,23 @@ input,textarea,select,.cbi-dropdown>ul>li{font-size:var(--fs-type-lg)}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"])>.ifacebox-body:nth-child(2){color:var(--fs-dim)}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"])>.ifacebox-body:nth-child(4){order:2;text-align:end;flex:1 0 100%}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"])>.ifacebox-body:nth-child(4)>.cbi-tooltip-container{font-family:var(--fs-font-mono);font-size:var(--fs-type-2xs) !important;color:var(--fs-dim);line-height:var(--fs-leading-tight);white-space:nowrap;text-align:end !important}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar){display:block}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar){display:grid;grid-template-columns:minmax(0,1fr);gap:var(--fs-space-1) var(--fs-space-2-5);padding:var(--fs-space-1) 0 var(--fs-space-1-5);border:0;position:relative}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child{padding-bottom:var(--fs-space-0-5)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td{display:block;width:auto;padding:0;border:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child{grid-column:1;grid-row:1;align-self:center;min-width:0;white-space:normal;padding-inline-end:min(230px,50%);font-size:var(--fs-type)}
@container fs-view (max-width:560px){:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child{padding-inline-end:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .cbi-progressbar{margin-block-start:calc(var(--fs-type-xs) * var(--fs-leading))}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar){display:block}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar){display:grid;grid-template-columns:minmax(0,1fr);gap:var(--fs-space-1) var(--fs-space-2-5);padding:var(--fs-space-1) 0 var(--fs-space-1-5);border:0;position:relative}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child{padding-bottom:var(--fs-space-0-5)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td{display:block;width:auto;padding:0;border:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child{grid-column:1;grid-row:1;align-self:center;min-width:0;white-space:normal;padding-inline-end:min(230px,50%);font-size:var(--fs-type)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar{margin-block-start:0}
@container fs-view (max-width:560px){:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child{padding-inline-end:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar{margin-block-start:calc(var(--fs-type-xs) * var(--fs-leading))}
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2){grid-column:1 / -1;grid-row:2}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr{border-bottom:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2){grid-column:1 / -1;grid-row:2}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr{border-bottom:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value{border-bottom:0}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child{font-weight:var(--fs-weight-normal);color:var(--fs-dim)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child{font-weight:var(--fs-weight-normal);color:var(--fs-dim)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active{background:var(--fs-panel2);color:var(--fs-good);font-weight:var(--fs-weight)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong{font-weight:var(--fs-weight-normal);color:var(--fs-dim)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after{font-size:var(--fs-type);color:var(--fs-text)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th{padding-block:var(--fs-space-1-5)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after{font-size:var(--fs-type);color:var(--fs-text)}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th),:has(.td:nth-child(3))) .td,:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th),:has(.td:nth-child(3))) .th{padding-block:var(--fs-space-1-5)}
:where(#view)[data-page="admin-system-package-manager"] .controls>div:first-child{position:relative;display:block;flex:1 1 100%;width:100%;margin:0 0 var(--fs-space-1-5)}
#packages:not(.fs-stacked) .tr>.td:first-child{white-space:nowrap}
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr){display:block;padding:var(--fs-space-3) var(--fs-space-4)}
@@ -1006,6 +1007,7 @@ input,textarea,select,.cbi-dropdown>ul>li{font-size:var(--fs-type-lg)}
#packages.fs-stacked .td.cbi-section-actions{justify-content:flex-end;margin-top:var(--fs-space-1-5)}
#packages.fs-stacked .td.cbi-section-actions::before,#packages.fs-stacked .td:not([data-title])::before{content:none}
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar){display:block;margin:0;font-size:var(--fs-type);font-weight:var(--fs-weight);color:var(--fs-dim)}
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar{margin-block-start:0}
:where(#view)[data-page="admin-system-admin-sshkeys"]>div{background:var(--fs-panel);border:var(--fs-hairline);border-radius:var(--fs-radius-lg);padding:var(--fs-space-4)}
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist{max-width:none}
#view table.fs-dt:is(.leases,.lases,.leases6):not(.fs-stacked) .td{white-space:nowrap}
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. What it does not need is the flat cyan tile the mark
used to be pasted on — that tile was the "outdated" part (issue #7), and it fought every
browser's tab strip. Transparent now, so the icon sits in whatever the browser draws.
is, not which theme is painting it. The mark carries no flat cyan tile: a tile fights every
browser's tab strip, so the background is transparent and the icon sits in whatever the
browser draws.
Colours are the mark's own: cyan arcs, dark-blue ring. On a DARK tab strip that ring is
nearly invisible, so it lightens under prefers-color-scheme — an SVG favicon is a live

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -52,11 +52,14 @@ function probeColor(expr) {
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* Every declaration is !important: this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
/* the id is this probe's escape from fs-router.js's strayBodyNode(): it is
* appended once and never removed, so without an fs-* id it would read as body litter and
* force every navigation away from this page into a full load. */
_probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
@@ -73,37 +76,28 @@ function probeColor(expr) {
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
* convert instead (the export tier is checked the same way). Canvas 2D is universal on every
* engine 24.10 ships against, so there is no second path. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
if (!_cx) {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
_cx = cv.getContext('2d', { willReadFrequently: true });
}
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = String(s || '');
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
/* translucent: composite over nothing is meaningless for a readout */
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
@@ -141,8 +135,8 @@ function toHex(s) {
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
function colorControl(onPick, label, opts) {
const o = opts;
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
@@ -155,10 +149,6 @@ function colorControl(current, onPick, label, opts) {
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
@@ -180,8 +170,8 @@ function colorControl(current, onPick, label, opts) {
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
* Class names are written out whole: dead CSS is found by matching fs-* tokens in the
* source, and a concatenated name is invisible to that match. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
@@ -207,7 +197,9 @@ function colorControl(current, onPick, label, opts) {
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
/* re-read rather than a cached copy: the page can change what the axis holds behind this
* control (a preset, Reset to default), which a snapshot taken at build time would miss */
else reflect(o.read());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
@@ -218,7 +210,7 @@ function colorControl(current, onPick, label, opts) {
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
wrap.fsRefresh = () => reflect(o.read());
return wrap;
}
@@ -309,14 +301,15 @@ function build() {
return node;
};
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
/* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is
* the live token the control reads the effective colour back from, `contrast` the pair it
* reports */
const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => {
const ctl = colorControl(bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
cls: (opts && opts.cls) || ''
cls: opts.cls
});
colourCtls.push(ctl);
return ctl;
@@ -326,7 +319,7 @@ function build() {
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
* because another catalogue translates "Top" as "maximum". Contexting cannot be
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
* has no catalogue for. */
@@ -344,7 +337,7 @@ function build() {
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
@@ -355,7 +348,7 @@ function build() {
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
* a proper noun like the three above it, not the English common noun "forum" */
forum: 'Forum'
}, bump(repaint(axes.applyPalette)), label)),
}, bump(repaint(axes.palette.apply)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
@@ -363,18 +356,18 @@ function build() {
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
/* issue #44: the content column's own cap, separate from Density (which moves type and
/* the content column's own cap, separate from Density (which moves type and
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
* shipped and only ever widens — there is no reason to offer a column narrower than the
* one every page was designed against, and keeping 1280 as the left end also keeps
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
* by construction rather than by a rule someone has to remember. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.currentContentWidth(), 1280, 3840,
bump(axes.applyContentWidth), label, { step: 40, live: false })),
(label) => sliderCtl(axes.contentWidth.current(), 1280, 3840,
bump(axes.contentWidth.apply), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
(label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)),
/* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
@@ -392,9 +385,7 @@ function build() {
const colours = [
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
* would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), {
current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', {
colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
* token this axis must not move, so the ratio is reported instead of corrected */
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
@@ -406,7 +397,7 @@ function build() {
* Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
(label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
@@ -419,12 +410,12 @@ function build() {
* eight times between here and the surfaces below, they cost their repeated literals in
* full — a string is not mangled — so the rows are data and the row is stated once. */
...[
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
].map(([ label, current, apply, ink ]) =>
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
[ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
].map(([ label, axis, ink ]) =>
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
];
/* ---- the surfaces: the sheet the UI is drawn on ----
@@ -439,12 +430,12 @@ function build() {
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
* for the three that carry body text and the hairline itself for the border. */
...[
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
];
/* ---- section 3: the wallpaper and the rows each value brings ----
@@ -493,30 +484,30 @@ function build() {
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600,
bump(axes.patternSize.apply), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100,
bump(axes.patternStrength.apply), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(axes.applyPatternInk), lbl))
}, bump(axes.patternInk.apply), lbl))
];
/* …and the rows the FILE photo brings. */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(axes.applyPhotoDim), lbl, { step: 5 }))
group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100,
bump(axes.photoDim.apply), lbl, { step: 5 }))
];
function reflect(tok) {
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
@@ -527,11 +518,11 @@ function build() {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(axes.currentLoginBg());
reflectPattern(axes.currentPattern());
togglePanel(axes.currentWallpaper());
reflect(axes.loginBg.current());
reflectPattern(axes.pattern.current());
togglePanel(axes.wallpaper.current());
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); };
/* Both uploads present the same three controls and the same four states — pick, upload,
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
@@ -579,7 +570,7 @@ function build() {
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(axes.currentWallpaper(), {
seg = selectCtl(axes.wallpaper.current(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
@@ -729,7 +720,7 @@ function build() {
* ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right
* one: apk learns it from `install-if` against `luci-i18n-base-<lang>` (owfeed.yml), opkg has no
* conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply
* loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its
* loses the catalogue that used to ride inside the theme. `install.sh` covers its
* own path; this covers the one nobody ran a script on.
*
* ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not
@@ -837,7 +828,7 @@ function build() {
]);
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
* the form to be in the document: every readout resolves against a hidden
* probe attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
@@ -12,7 +12,7 @@
* write fails after the bytes have landed.
*
* It is a module of its own because of WHERE it is needed: this machinery is reached only from the
* Appearance page, one out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
* Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
* plumbing downloaded to a router's browser on the way to the DHCP page.
*
* The token accessors and the two live appliers live in `fs-axes` beside the axes themselves — the
@@ -212,20 +212,8 @@ function _sanitizeSvg(text) {
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
* one, and without the PI the embedded stylesheet is never applied. */
/* Residual, verified NOT exploitable here (2026-09 security review): a prologue of
* `<!DOCTYPE svg SYSTEM "http://evil.example/x.dtd">` is a document-level construct this walk
* never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an
* element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip
* that runs below when anything else on the document needs cleaning. It costs nothing today
* because no shipping engine resolves an external DTD for any document at all, `image/svg+xml`
* included (external entity resolution has been off since the XXE era); a browser that ever did
* would only reach that fetch by opening the file directly, which the CGI answers with
* `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle
* only as a `mask-image`/`background-image`/`<img>` source, none of which parses a DOCTYPE at all.
* Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise
* judges only elements and their attributes, for a fetch nothing in this project's support matrix
* performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or
* the file is ever served as a navigable document without that header. */
/* An external-DTD DOCTYPE prologue is left unvisited on purpose: no engine resolves an external DTD, and
* the CGI answers a direct open with CSP `default-src 'none'; sandbox`. */
for (const n of [ ...doc.childNodes ])
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
@@ -265,21 +253,8 @@ function _sanitizeSvg(text) {
* one the spec gives them — a change to how SVG itself is interpreted, not something a
* check here could see coming or catch. */
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
/* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value
* normalisation does not fold a numeric character reference the way HTML does, so
* `href="javas&#9;cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in
* it — `/^javascript:/i` does not match a value starting with a control character, and the
* fixture round-trips through `XMLSerializer` byte for byte, so it survives both this
* filter and the serializer. It still resolves to `javascript:` in a real browser, because
* a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme
* start state") — the same class of hole every naive `^scheme:` regex has. Not closed:
* matching every control character a URL parser discards is the grammar-guessing this file
* exists to avoid, for a value that never resolves as a URL here in the first place — this
* bundle only ever leaves the router as `mask-image`/`background-image`/`<img>`, none of
* which reads `href` as navigable, and the CGI serving it back for a direct open sends
* `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the
* day the tile is served without that header, or inlined into a page rather than
* referenced as an image. */
/* A TAB-obfuscated `javascript:` (`href="javas&#9;cript:alert(1)"`) passes this regex but
* is only ever an image source, never a navigable `href`, and a direct open is CSP-sandboxed. */
if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; }
/* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference —
* it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly
@@ -296,22 +271,9 @@ function _sanitizeSvg(text) {
* prefix to the XML namespace just to carry it is a narrower attack this does not chase,
* consistent with this file avoiding a second grammar it would have to maintain. */
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
/* Residual, verified NOT exploitable here (2026-09 security review): the branch below is
* the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same
* function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) —
* is a plain attribute value to this walk, not a CSS declaration, so `<rect
* fill="url(//evil.example/x.svg#g)"/>` matches nothing above and passes with its ORIGINAL
* bytes untouched. What it can do is start a second fetch — no script, no navigation,
* exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs
* only where the SVG is rendered with its own resource-loading context, which this bundle
* never is: it is only ever a `mask-image`/`background-image`/`<img>` source, and a direct
* open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox`
* regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the
* same check against every presentation attribute the SVG spec lets carry a `url()`, a list
* this file would then have to track as the spec grows it — the grammar this file exists to
* avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a
* presentation `url()` gets a fetch path this parser-only pass does not already block by
* other means, or the tile stops being image-only. */
/* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below
* for `style`, not here: the bundle is only an image source, so the
* worst reach is a second fetch with no script and no navigation. */
if (n === 'style') {
const cleaned = _sanitizeStyleValue(v);
if (cleaned.removed) {
@@ -337,22 +299,11 @@ function _sanitizeSvg(text) {
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
}
/* read the picked file as text so it can be inspected before upload, and so what reaches the
* router is exactly the bytes that were checked */
function _readText(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
fr.readAsText(file);
});
}
/* ---- login/page background upload: router-side, and deliberately not an axis ----
* The other axes are per-browser with a router default; this one has no browser layer. An admin
* uploads an image once, it becomes the router-wide background for every device and shows
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
* move the Save button — and needs no factory, so tools/axes.mjs never sees it.
* move the Save button — and needs no factory, so axis-contract checks never see it.
*
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
@@ -490,7 +441,7 @@ function assetAxis(o) {
.catch((e) => _rollbackUpload(o.path, e))
.then(() => {
/* switch this browser onto it: the ordinary axis path, localStorage only */
axes.applyWallpaper(o.wallpaper);
axes.wallpaper.apply(o.wallpaper);
o.apply(tok);
return tok;
});
@@ -510,13 +461,16 @@ function assetAxis(o) {
* back a raster, so the parsed-document check above stands in for it. */
const PATTERN = assetAxis({
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
apply: (tok) => axes.applyPattern(tok),
apply: (tok) => axes.pattern.apply(tok),
prepare: (file) => {
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
return _readText(file).then((text) => {
/* the catch keeps the same message, so what reaches the router is still exactly the bytes
* that were checked */
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
return file.text().catch(readErr).then((text) => {
const cleaned = _sanitizeSvg(text);
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
/* Told once, here, regardless of whether the upload that follows succeeds — what left
@@ -535,7 +489,7 @@ const PATTERN = assetAxis({
* grant for BG_PATH. */
const LOGIN_BG = assetAxis({
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
apply: (tok) => axes.applyLoginBg(tok),
apply: (tok) => axes.loginBg.apply(tok),
prepare: (file) => {
if (!file || !(/^image\//).test(file.type || ''))
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
@@ -10,20 +10,19 @@
* chrome, the menu and the search palette, so it is fetched on every admin page — and of its
* sixty-one exports the cold path called eight. Everything here is reached only from
* `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
* fetches it on the Appearance page and nowhere else. Measured: 6.6 KB off what every page
* fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
* downloads.
*
* What stayed behind in `fs-prefs`, and why:
* - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
* - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
* `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
* `stampDark()` is checked against that file by path
* - layout, density, rail and auto-collapse, because the chrome and the menu apply them live —
* and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
* through `L.require('fs-prefs')` to sweep their matrix
* and because external probes stamp layout and density through `L.require('fs-prefs')`
*
* The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
* is needed to PAINT a page correctly — only to change one from the form. tools/axes.mjs reads the
* whole resources directory rather than a path, precisely so an axis may live in a second file. */
* is needed to PAINT a page correctly — only to change one from the form. The axis list is read
* from the whole resources directory rather than one path, so an axis may live in a second file. */
const FS_RADIUS_DEFAULT = 12;
@@ -117,11 +116,10 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
* it and the first touch of any other control takes it away.
*
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
* PALETTE.current() on a loaded page; the stray fallthrough covers them anyway. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
* recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
@@ -134,7 +132,6 @@ const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
* in the list falls back to 'off'. */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
@@ -145,14 +142,12 @@ const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
* otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
const currentTint = TINT.current, applyTint = TINT.apply;
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
* separate because they carry separate meanings and every derived tint is a color-mix() of the
@@ -197,27 +192,18 @@ function surfaceAxis(key, sdKey, prop) {
};
}
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
const currentCard = CARD.current, applyCard = CARD.apply;
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const currentBar = BAR.current, applyBar = BAR.apply;
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
const currentLine = LINE.current, applyLine = LINE.apply;
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
const currentGood = GOOD.current, applyGood = GOOD.apply;
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
const currentWarn = WARN.current, applyWarn = WARN.apply;
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
/* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own
/* Content width: how far the reader lets the column grow past --fs-content-max's own
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
* 02-tokens.css, next to the token it bounds, and is not restated here.
*
@@ -228,7 +214,6 @@ const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault
const FS_CWIDTH_DEFAULT = 1280;
const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840,
FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule());
const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.def;
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
@@ -256,14 +241,12 @@ const AXIS_KEYS = [
* module throws, taking the chrome and the menu with it. */
const FS_TSTR_DEFAULT = 100;
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
@@ -273,14 +256,11 @@ const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefaul
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
const FS_PSIZE_DEFAULT = 440;
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
const FS_PSTR_DEFAULT = 20;
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
@@ -294,30 +274,30 @@ function snapshotAxes() {
return {
layout: prefs.currentLayout(),
darkmode: prefs.currentMode(),
palette: currentPalette(),
wallpaper: currentWallpaper(),
tint: String(currentTint()),
accent: String(currentAccent()),
good: String(currentGood()),
warn: String(currentWarn()),
danger: String(currentDanger()),
card: String(currentCard()),
control: String(currentControl()),
bar: String(currentBar()),
line: String(currentLine()),
rounding: String(currentRadius()),
palette: PALETTE.current(),
wallpaper: WALLPAPER.current(),
tint: String(TINT.current()),
accent: String(ACCENT.current()),
good: String(GOOD.current()),
warn: String(WARN.current()),
danger: String(DANGER.current()),
card: String(CARD.current()),
control: String(CONTROL.current()),
bar: String(BAR.current()),
line: String(LINE.current()),
rounding: String(RADIUS.current()),
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
tint_strength: String(currentTintStrength()),
tint_strength: String(TSTR.current()),
density: prefs.currentDensity(),
photo_dim: String(currentPhotoDim()),
pattern_size: String(currentPatternSize()),
pattern_strength: String(currentPatternStrength()),
pattern_ink: currentPatternInk(),
content_width: String(currentContentWidth())
photo_dim: String(PDIM.current()),
pattern_size: String(PSIZE.current()),
pattern_strength: String(PSTR.current()),
pattern_ink: PINK.current(),
content_width: String(CWIDTH.current())
};
}
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance page can grey the Save button when this browser already shows exactly it.
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
* match without a reload.
*
@@ -341,15 +321,15 @@ function _resolvedDefault() {
control: String(CONTROL.def()),
bar: String(BAR.def()),
line: String(LINE.def()),
rounding: String(radiusDefault()),
rounding: String(RADIUS.def()),
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
tint_strength: String(tintStrengthDefault()),
tint_strength: String(TSTR.def()),
density: prefs.densityDefault(),
photo_dim: String(photoDimDefault()),
pattern_size: String(patternSizeDefault()),
pattern_strength: String(patternStrengthDefault()),
photo_dim: String(PDIM.def()),
pattern_size: String(PSIZE.def()),
pattern_strength: String(PSTR.def()),
pattern_ink: PINK.def(),
content_width: String(contentWidthDefault())
content_width: String(CWIDTH.def())
};
}
let _savedDefault = _resolvedDefault();
@@ -405,64 +385,39 @@ function resetToBuiltin() {
* first click — the one state no static gate can see and the live check does. */
prefs.applyLayout('top');
prefs.applyMode('auto');
applyPalette('footstrap');
applyWallpaper('off');
applyTint(0);
applyAccent(0);
applyGood(0);
applyWarn(0);
applyDanger(0);
applyCard(0);
applyControl(0);
applyBar(0);
applyLine(0);
applyRadius(FS_RADIUS_DEFAULT);
PALETTE.apply('footstrap');
WALLPAPER.apply('off');
TINT.apply(0);
ACCENT.apply(0);
GOOD.apply(0);
WARN.apply(0);
DANGER.apply(0);
CARD.apply(0);
CONTROL.apply(0);
BAR.apply(0);
LINE.apply(0);
RADIUS.apply(FS_RADIUS_DEFAULT);
prefs.applyAutoCollapse('off');
applyTintStrength(FS_TSTR_DEFAULT);
TSTR.apply(FS_TSTR_DEFAULT);
prefs.applyDensity('normal');
applyPhotoDim(FS_PDIM_DEFAULT);
applyPatternSize(FS_PSIZE_DEFAULT);
applyPatternStrength(FS_PSTR_DEFAULT);
applyPatternInk('theme');
applyContentWidth(FS_CWIDTH_DEFAULT);
PDIM.apply(FS_PDIM_DEFAULT);
PSIZE.apply(FS_PSIZE_DEFAULT);
PSTR.apply(FS_PSTR_DEFAULT);
PINK.apply('theme');
CWIDTH.apply(FS_CWIDTH_DEFAULT);
}
/* ---- the two uploaded wallpapers, browser side ----
*
* What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
* fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
* Appearance page and so not worth downloading on every admin page. The token accessors stay here
* Appearance tab and so not worth downloading on every admin page. The token accessors stay here
* because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
*
* Neither is an axis: an axis is per-browser with a router default, and these have no browser
* layer — one admin uploads once and every device sees it, pre-login included. So they are absent
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
/* NOT a /www symlink, unlike BG_SERVE below: an SVG served as a plain static file is a same-origin
* document that runs script opened directly (OpenWrt forum thread 251930). This CGI handler reads
* the fixed /etc/footstrap/pattern.svg path itself and answers with CSP 'none' + sandbox and
* nosniff, headers uhttpd cannot attach to a static file — see the uci-default and the handler for
* the rest of the reasoning. Painting stays exactly as before: a URL is a URL to a mask-image. */
const PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern';
function currentPattern() {
const t = prefs.sd('pattern');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
function applyPattern(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
else root.style.removeProperty('--fs-pattern-url');
prefs.setSD('pattern', tok || '');
}
/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE above: fs-assets.js
* re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the stored bytes
* are pixels only — there is no script grammar left in a raster for a direct hit to run. */
const BG_SERVE = '/luci-static/footstrap/bg';
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
* module — see the axes contract in head.ut. */
@@ -471,45 +426,67 @@ const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
* A predicate rather than the pattern itself, so the charset stays stated once. */
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
* pre-paint use, so the Appearance page can build a cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = prefs.sd('login_bg');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
function applyLoginBg(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
else root.style.removeProperty('--fs-login-bg-url');
prefs.setSD('login_bg', tok || '');
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust TOKEN,
* read back through prefs.sd(), that sets or clears one url() custom property. Neither is an axis
* (see the comment above) — this factory is what a bare url() setter shares with a real axis's
* apply(), not membership in AXIS_KEYS. `serve` differs for a reason stated at each call below. */
function tokenAsset(sdKey, serve, prop) {
const current = () => {
const t = prefs.sd(sdKey);
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
};
const url = (tok) => serve + '?v=' + tok;
/* set/clear the url() live; whether it PAINTS is the Wallpaper axis (data-wallpaper="pattern"
* or "file"). Exported because fs-assets.js applies the token it just wrote. */
const apply = (tok) => {
const root = document.documentElement;
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
else root.style.removeProperty(prop);
prefs.setSD(sdKey, tok || '');
};
return { current, url, apply };
}
/* NOT a /www symlink, unlike LOGIN_BG's plain static path below: an SVG served as a plain static
* file is a same-origin document that runs script opened directly (OpenWrt forum thread 251930).
* This CGI handler reads the fixed /etc/footstrap/pattern.svg path itself and answers with CSP
* 'none' + sandbox and nosniff, headers uhttpd cannot attach to a static file — see the uci-default
* and the handler for the rest of the reasoning. Painting stays exactly as before: a URL is a URL
* to a mask-image. */
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
/* Still the plain uhttpd symlink the uci-default makes, unlike PATTERN's CGI handler above:
* fs-assets.js re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the
* stored bytes are pixels only — there is no script grammar left in a raster for a direct hit to
* run. */
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
/* Every axis is exported as the object itself — `{ current, apply, def? }` — not a flat
* currentX()/applyX() pair. `pattern` and `loginBg` have the same tokenAsset() shape and export the
* same way; the router-default helpers (tokenOk, matchesSavedDefault, ...) stay flat. */
return baseclass.extend({
currentPalette, applyPalette,
currentWallpaper, applyWallpaper,
currentTint, applyTint,
currentAccent, applyAccent,
currentGood, applyGood,
currentWarn, applyWarn,
currentDanger, applyDanger,
currentCard, applyCard,
currentControl, applyControl,
currentBar, applyBar,
currentLine, applyLine,
currentRadius, applyRadius,
currentTintStrength, applyTintStrength,
currentPhotoDim, applyPhotoDim,
currentPatternSize, applyPatternSize,
currentPatternStrength, applyPatternStrength,
currentPatternInk, applyPatternInk,
currentContentWidth, applyContentWidth, contentWidthDefault,
palette: PALETTE,
wallpaper: WALLPAPER,
tint: TINT,
accent: ACCENT,
good: GOOD,
warn: WARN,
danger: DANGER,
card: CARD,
control: CONTROL,
bar: BAR,
line: LINE,
radius: RADIUS,
tintStrength: TSTR,
photoDim: PDIM,
patternSize: PSIZE,
patternStrength: PSTR,
patternInk: PINK,
contentWidth: CWIDTH,
pattern: PATTERN,
loginBg: LOGIN_BG,
currentPattern, patternUrl, applyPattern,
currentLoginBg, loginBgUrl, applyLoginBg,
tokenOk,
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
});
@@ -4,6 +4,7 @@
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-widgets as widgets';
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
@@ -18,11 +19,8 @@ function setRenderMain(fn) {
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
/* #tabmenu is emitted whenever this module loads (notices.ut, !blank_page) */
const container = document.querySelector('#tabmenu');
/* a template without the container must not reject: an unhandled rejection here kills the
* whole ui.menu.load() chain, i.e. every menu */
if (!container)
return E([]);
const ul = E('ul', { 'class': 'tabs' });
const children = ui.menu.getChildren(node);
let activeNode = null;
@@ -39,15 +37,13 @@ function renderTabMenu(node, url, level) {
});
if (ul.children.length === 0)
return E([]);
return;
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
return ul;
}
/* ---- tab-strip auto-fit ----
@@ -107,13 +103,19 @@ let _probe = null;
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
* therefore !important, which a style-attribute wins outright: an app carrying
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* on a 1857px desktop — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
* the content box, so a foreign `border-box` plus padding would shave the reading. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
/* the id is fs-router.js's strayBodyNode() reading an fs-* id as "ours", which is
* a different question from the chrome mark above: this stays unmarked (no data-fs-chrome,
* so the fence still does not spare it from a hostile !important) but still needs SOME fs-*
* name, or an element appended once and never removed would read as body litter and force
* every navigation off this page into a full load. */
_probe.id = 'fs-chrome-geom-probe';
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
@@ -150,21 +152,20 @@ function shellGeometry() {
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === key) return _geom;
_geomDensity = key;
const px = (name, dflt) => resolveLen(name, dflt);
const g = {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: px('--fs-rail-w', GEOM_DFLT.railW),
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW),
/* the token is one side's padding; the column loses it twice. It is only the fallback —
* measureShell() overwrites this with the gutter the column actually got, which nothing
* has measured before the first fitter (and the login page has no `.fs-content`). */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
contentPad: resolveLen('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
contentMax: resolveLen('--fs-content-max', GEOM_DFLT.contentMax)
};
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
* reports one foreign width for all four, a renamed or absent token reports 0 for
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
* never fires). Only the relation between the numbers gives either away. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
@@ -230,7 +231,7 @@ function columnWidth(g, state) {
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
* lower bound), but this is the exported answer to "how wide is the content column". */
* lower bound), but this is the answer to "how wide is the content column". */
const room = Math.min(state.outerW - cut, g.contentMax);
return Math.max(0, room - g.contentPad);
}
@@ -278,11 +279,11 @@ function fitChrome() {
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
* 123px at 767px — 107px of growth a floor never sees, on top of the shrink it does
* — each step landing between two of the poll's own separate section refreshes, so the browser
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
* as Safari (`tools/fit-quiet.mjs`, plus a probe that pauses the layout mid-pass). `min-height`
* alone was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
* as Safari (measured with a probe that pauses the layout mid-pass). `min-height`
* alone was measured against an earlier WebKit-only diagnosis instead — WebKit's own scroll anchoring
* looked like the whole story only because it is the one engine with no anchoring at all to hide
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
* change, which is not the same as not producing it.
@@ -401,6 +402,7 @@ function clusterFitsBrandRow(bar, menu) {
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
@@ -409,18 +411,14 @@ function renderModeMenu(node, renderMainMenu) {
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even where a template has no #modemenu */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (!ul)
return;
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
@@ -431,13 +429,15 @@ function renderModeMenu(node, renderMainMenu) {
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
/* #modemenu/#topmenu/#tabmenu are emitted whenever this module loads (header.ut/notices.ut,
* !blank_page) */
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
if (topmenu) topmenu.innerHTML = '';
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
topmenu.innerHTML = '';
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
renderModeMenu(root, _renderMain);
@@ -461,8 +461,8 @@ function renderChrome() {
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
* CSS keyed off that attribute. */
function wireRail() {
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
function sync() {
const on = prefs.currentRail();
@@ -484,7 +484,7 @@ function wireRail() {
}
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge. CSS
* squares the pill there and draws this attribute instead of the label — a text node cannot be
* reached by a selector, so the badge is lifted into an attribute here.
*
@@ -494,38 +494,23 @@ function wireRail() {
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
* not export it). */
function syncIndAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
* span: no role, name or tabindex, so Tab skips it and a screen reader
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
* never invented. widgets.wireActivate() calls el.click() because a <span>, unlike an <a>, gets
* neither Enter nor Space for free; calling click() cannot double-fire the mouse handler, since a
* keydown is not a click. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
el.click();
});
widgets.wireActivate(el, () => el.click());
}
function wireIndicatorCounts() {
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
const box = document.getElementById('indicators');
if (!box) return;
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
@@ -541,9 +526,9 @@ function wireIndicatorCounts() {
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
* `[data-clickable]` for the same reason; the two must not disagree. */
if (el.hasAttribute('data-clickable')) {
syncIndAttr(el, 'role', 'button');
syncIndAttr(el, 'tabindex', '0');
syncIndAttr(el, 'aria-label', txt.trim() || null);
widgets.syncAttr(el, 'role', 'button');
widgets.syncAttr(el, 'tabindex', '0');
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
@@ -601,8 +586,7 @@ return baseclass.extend({
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
* stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390, against a probe
* resizing the window mid-settle, and live-audit's `geometry|fs-content` finding on owrt2410,
* CI run 34364446910).
* resizing the window mid-settle, and a live geometry audit on 24.10).
*
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
@@ -621,8 +605,5 @@ return baseclass.extend({
});
},
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
* arithmetic directly is the only way to cover every combination of layout, rail and width
* without a browser */
wireRail
});
@@ -1,6 +1,5 @@
'use strict';
'require baseclass';
'require ui';
/* fs-fit — the theme's one "does it still fit?" engine and the one place the reader's scroll
* position is corrected. Add fit logic here, never a second observer: no CSS query can ask what the
@@ -15,9 +14,9 @@
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
function armGate() {
if (!fittersEnabled()) return;
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
* reads this file for exactly that spelling to prove the gate rule is still armed, and an
* indirection hides the write from it. The behaviour survives being factored out; the
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: the theme's own
* repository checks this file for exactly that spelling to prove the gate rule is still armed,
* and an indirection hides the write from it. The behaviour survives being factored out; the
* contract does not. */
try { document.documentElement.dataset.fsFit = '1'; }
catch (e) { /* no document, no flag to write */ }
@@ -60,28 +59,38 @@ const FLOORED = '[data-fs-floor]';
/* One floor per container, cleared and re-measured in one batched pass: not while the reader
* scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest (issue #41). */
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest. */
function holdFloor(records) {
if (scrolling()) return;
const host = document.getElementById('view');
if (!host) return; /* the login page has no view */
const boxes = [], hs = [];
/* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a
* Set alongside it for the O(1) "already have this one" check. */
const boxes = [], boxSeen = new Set(), hs = [];
host.querySelectorAll(SHRINKS).forEach((el) => {
let box = el, cs = window.getComputedStyle(el);
while (box && box !== host && cs.display.startsWith('table')) {
box = box.parentElement;
if (box) cs = window.getComputedStyle(box);
}
if (!box || box === host || boxes.indexOf(box) !== -1) return;
if (!box || box === host || boxSeen.has(box)) return;
boxSeen.add(box);
boxes.push(box);
});
host.querySelectorAll(FLOORED).forEach((box) => {
if (boxSeen.has(box)) return;
boxSeen.add(box);
boxes.push(box);
});
host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });
let dirty = boxes;
if (records && records.length) {
const targets = [];
for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
const targets = new Set();
for (const r of records) if (r.target) targets.add(r.target);
dirty = boxes.filter((box) => {
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
return false;
});
}
if (!dirty.length) return;
@@ -363,14 +372,29 @@ function anchorRef() {
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}
let _anchorPending = null;
let _anchorWhy = null;
const _anchorTrail = [];
function awhy(w) {
_anchorWhy = w;
_anchorTrail.push(w + '@' + Math.round(performance.now()));
if (_anchorTrail.length > 8) _anchorTrail.shift();
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
* the late drift correction further down) — same shape twice: the last reason recorded, and the
* last 8 with a timestamp. They are read back only by the theme repository's
* development build; the package exports none of it. One factory, so the cap and the timestamp
* format cannot drift between the two. */
const TRAIL_MAX = 8;
function makeTrail() {
let last = null;
const entries = [];
return {
record(w) {
last = w;
entries.push(w + '@' + Math.round(performance.now()));
if (entries.length > TRAIL_MAX) entries.shift();
},
last: () => last,
entries: () => entries
};
}
let _anchorPending = null;
const _anchor = makeTrail();
function awhy(w) { _anchor.record(w); }
let _anchorFrame = 0;
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
@@ -380,14 +404,8 @@ function anchorEnabled() {
* It keeps a reference still; it does not promise that a section can vanish and come back. An
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
let _lateFrame = 0;
/* why the last late correction did or did not write — eight exits, one symptom from outside */
let _lateWhy = null;
const _lateTrail = [];
function why(w) {
_lateWhy = w;
_lateTrail.push(w + '@' + Math.round(performance.now()));
if (_lateTrail.length > 8) _lateTrail.shift();
}
const _late = makeTrail();
function why(w) { _late.record(w); }
function lateDrift(ref, grow, floorShrink) {
if (_lateFrame) return why('busy');
@@ -607,11 +625,6 @@ return baseclass.extend({
},
scrolling,
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
lateWhy: () => _lateWhy,
lateTrail: () => _lateTrail.slice(),
anchorWhy: () => _anchorWhy,
anchorTrail: () => _anchorTrail.slice(),
deferMeasurement,
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
@@ -19,7 +19,7 @@ function setTree(tree) {
* reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
if (base && !pathname.startsWith(base))
return null;
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
return rest.length ? rest.split('/') : [];
@@ -159,10 +159,6 @@ function currentNode() {
return baseclass.extend({
setTree,
tree: () => _tree,
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
* node whose `depends.acl` names the group that command needs, and a node that resolves
* elsewhere would answer for a permission the session may not hold */
nodeForSegs,
segsFromPath,
currentNode,
resolveSegs,
@@ -3,6 +3,7 @@
'require dom';
'require network';
'require fs-fit as fit';
'require fs-widgets as widgets';
'require menu-footstrap-common as common';
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
@@ -21,8 +22,6 @@
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
/* the data-page value four call sites compare against; a string literal is not mangled, so a
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
function headerEl(sec) {
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
@@ -46,27 +45,15 @@ function sectionTitle(sec) {
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
*
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
* On 25.12, Status -> Overview carries 14 `[data-clickable]` elements — the
* topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
* docs/findings.md, "A card cannot be collapsed from the keyboard".
*
* index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
* not a competing one. */
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
* imported, since that file does not export it. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
* (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
* same way. */
@@ -96,14 +83,14 @@ function wireDisclosure(sec) {
const panel = panelFor(h);
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
syncAttr(h, 'role', 'button');
syncAttr(h, 'tabindex', '0');
syncAttr(h, 'aria-controls', panel ? panel.id : null);
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(h, 'role', 'button');
widgets.syncAttr(h, 'tabindex', '0');
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
* to a second, unlabelled clickable span would hear an unexplained duplicate control — same
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
syncAttr(label, 'aria-hidden', 'true');
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js */
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
@@ -112,22 +99,18 @@ function wireDisclosure(sec) {
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
* too would toggle the card twice */
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
/* the pill is an <a>-less <span>, so neither key is native here — contrast
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
* and needs only Space added */
h.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
label.click();
});
* menu-footstrap.js's wireSpaceKey, written for an <a role="button">, which gets Enter for
* free and needs only Space added */
widgets.wireActivate(h, () => label.click());
}
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
* "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
* not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is
* suppressed rather than left as a rung in the tab order with nothing behind its own name. */
* not proof of position). Real and cosmetic, so it is suppressed rather than left as a rung in the
* tab order with nothing behind its own name. */
function hideEmptyCard(sec) {
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
}
@@ -165,8 +148,8 @@ function arrange() {
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: tools/page-modules.mjs reads this value out of the module's
* SOURCE to check it against the map in menu-footstrap-common.js */
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: this value is read out of the module's
* SOURCE and checked against the map in menu-footstrap-common.js */
'admin-status-overview') {
stopWatch();
return;
@@ -216,15 +199,13 @@ function arrange() {
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
* re-attach when the observed node is no longer the current one — a singleton bound to the first
* #view would watch a detached tree and the grid would never apply again. */
let _observer = null, _observedRoot = null, _routeObserver = null;
/* Stock sections render async and repaint every poll, so watch #maincontent and re-run arrange(),
* coalesced. #maincontent (unlike #view) outlives every SPA swap, so one observer, attached once,
* is never stale and never needs to be re-attached. */
let _observer = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = null;
_observedRoot = null;
_wrapEl = null; /* the grid belongs to the #view we are leaving */
}
function watch() {
@@ -234,16 +215,12 @@ function watch() {
* first — so the observer bound here read `isConnected: false` after one round trip and the
* grid stopped being re-arranged on every poll tick, silently, until the next full load. The
* shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
const root = document.getElementById('maincontent') || view;
if (_observer && _observedRoot !== root)
stopWatch();
const root = document.getElementById('maincontent');
arrange();
/* a chrome module is alive on every page, so without the route check an observer would attach
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
/* the caller (onOverview(), via wire()'s data-page observer) already confirmed the overview
* route before reaching here; !view alone covers the mid-navigation race above */
if (_observer || !view)
return;
_observedRoot = root;
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
* theme's shared coalescer, fs-fit.js) */
_observer = new MutationObserver(fit.frame(arrange));
@@ -1,14 +1,13 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-fit as fit';
/* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The
* axis list is AXIS_KEYS, which is exactly the fields of snapshotAxes() — what Save-as-default
* writes.
* All client-side, instant and persisted in localStorage, with head.ut's inline script re-applying
* them before paint so a reload never flashes the wrong one; tools/axes.mjs derives the contract
* from this file and holds the two copies to it.
* them before paint so a reload never flashes the wrong one; the contract is derived
* from this file and both copies are held to it.
*
* ---- three layers, and the browser always wins ----
* Every axis resolves as localStorage ?? router-default ?? built-in. The router default is
@@ -44,13 +43,15 @@ function lsGetArr(k) {
}
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
* effective default when this browser has no localStorage */
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
* effective default when this browser has no localStorage. No try/catch: unlike localStorage,
* reading or writing a plain object property cannot throw, and window.__fsSD is always one (head.ut
* stamps it as an object literal). */
function sd(k) { return (window.__fsSD || {})[k]; }
/* …and the write back: an applier that persists to the router must update the blob the server
* stamped, or current*() keeps reporting the old router default until the next full load and
* matchesSavedDefault() lies about whether anything is left to save */
function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
* `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed
@@ -65,7 +66,6 @@ function currentMode() {
const s = lsGet('fs-darkmode');
if (s === 'true') return 'dark';
if (s === 'false') return 'light';
if (s === 'auto') return 'auto';
if (s === null) return modeDefault();
return 'auto';
}
@@ -79,14 +79,13 @@ function currentMode() {
*
* `data-darkmode` is the name the theme's own CSS keys off. The other two are outbound
* compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and
* tools/axes.mjs fails the build if it does. */
* a check fails if one does. */
/* the attribute name reused below by the writer, the guard's reader and both MutationObserver
* filters (measured: 15 B x4 -> 28 B, 32 B saved) */
function stampDark(root, dark) {
/* the literal stays spelled out HERE: tools/axes.mjs reads the attribute names out of
* this function's SOURCE, so a hoisted const reads as no attribute at all and the gate
* reports the pre-paint and the live applier as drifted. The 45 B a const would save are
* not worth teaching a gate to resolve them. */
/* the literal stays spelled out HERE: the attribute names are read out of this
* function's SOURCE, so a hoisted const reads as no attribute at all and the pre-paint and the
* live applier look drifted. The 45 B a const would save are not worth resolving them. */
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
@@ -169,18 +168,11 @@ function paintThemeColor() {
}
function watchThemeColor() {
let queued = false;
const paint = () => {
queued = false;
paintThemeColor();
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
paintThemeColor();
new MutationObserver(schedule).observe(document.documentElement,
/* fit.frame() is the same next-frame coalescer every geometry fitter schedules through: the
* tint and strength sliders write on every input event, and getComputedStyle forces style
* resolution, so a dozen mutations in one frame must still repaint the meta tag once. */
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
}
@@ -192,28 +184,6 @@ _mqDark.addEventListener('change', () => {
stampDark(root, intendedDark());
}
});
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
* to this one number, hence the named const. */
/* ---- the four axis shapes, each written once ----
*
* Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line:
* enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
* strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
* default alone, `apply()` stores the choice explicitly. None use `this` — every export is a
* detached method reference, so a `this` here would throw on the first call.
*
* Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the
* call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate
* to find.
*
* The remaining axes stay separate, each with a quirk a shared table would need an option for:
* `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute,
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
* one landed, `autoCollapse` has no :root attribute. */
/* An axis whose values are a list, with one of them stamped as nothing.
*
* `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
@@ -235,7 +205,6 @@ function listAxis(key, attr, values, dflt, after) {
current() {
const s = lsGet(key);
if (ok(s)) return s;
if (s === dflt) return dflt;
if (s === null) return def();
return dflt; /* a stray value reads as the built-in default */
},
@@ -252,42 +221,21 @@ function listAxis(key, attr, values, dflt, after) {
}
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
* with a list of one — kept as its own name because tools/axes.mjs matches the call, and because
* with a list of one — kept as its own name because the call is matched by name, and because
* "two-valued" is what most of these axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
* attribute, or a fresh load paints one frame in the previous colour).
*
* A value is one of three things, and the attribute says which (03-palettes.css matches on it):
*
* 0 off — no attribute, the palette as it shipped
* 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so
* lightness, chroma and every contrast margin stay the palette's
* '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived
* from its lightness in CSS
*
* Both live in one localStorage key rather than a colour key beside a hue key: two keys would need
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
* clears the other's property, so the two can never both be half-applied. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
densityDefault = DENSITY.def;
/* Content width lives entirely in fs-axes.js now (a propAxis like Rounding, issue #44): unlike
/* Content width lives entirely in fs-axes.js (a propAxis like Rounding): unlike
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance page — the one
* contentWidth.current()/apply() have no caller outside the Appearance tab — the one
* thing that kept Density's shape in this cold-path file. */
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
}
@@ -342,23 +290,6 @@ function currentRail() {
return document.documentElement.getAttribute('data-rail') === 'true';
}
/* ---- Save to router: write the current effective axes to /etc/config/footstrap ----
* The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those
* options; rpcd validates the config/section/option names, so no value reaches a shell. The server
* reads them back on the next load and head.ut's sanitiser clamps each before it becomes
* window.__fsSD.
*
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
*
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
* and 188 B larger after minification — three lists of short literals compress better than
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
* field against header.ut's FS_AXES. */
return baseclass.extend({
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
@@ -25,7 +25,7 @@
* The path->node half lives in fs-menutree.js (the chrome needs it too); the "has a view poisoned
* this document with its CSS?" half in fs-sheets.js.
*
* `docs/…`, `tools/…` and `tests/…` in these comments name the theme's own repository
* `docs/…` in these comments names the theme's own repository
* (https://github.com/VizzleTF/luci-theme-footstrap), not the tree this file is read in: the
* package ships the runtime and nothing else. */
@@ -43,9 +43,9 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
* keyed by that first id and the entry carries `live`, the id armed right now (null while
* paused), so a view holding its handle can still stop its own poller after a trip through a
* hidden tab. The arguments are kept because a `setInterval` id carries none of them back. */
window.setInterval = function (fn, ms) {
window.setInterval = function (fn, ms, ...rest) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
_viewIntervals.set(id, { fn, ms, rest, live: id });
return id;
};
window.clearInterval = function (id) {
@@ -102,28 +102,19 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
*
* `L.Poll.timer` is that id, and it is private state: `add`/`remove`/`start`/`stop`/`active` are the
* documented surface, and the whole `L.Poll` alias is already deprecated (`'require poll'` replaces
* it, but no supported release ships poll.js yet). Read blind, a renamed field would make LuCI's
* tick look like a view's — cleared on the next navigation, every poll on every later page silently
* dead. So a missing field is a reason to do nothing, once, loudly.
*
* Asked through the documented half first: `active()` says whether the tick is running, and `timer`
* is deleted by `stop()`, so an absent field is the ordinary "nothing to protect" case. The anomaly
* worth reporting is the pair disagreeing — a tick running while the id it runs on has no name we
* know.
*
* The alias itself is guarded for the same reason: the sweep runs inside the staged render, and a
* TypeError there would leave every click showing the previous page's content under the new page's
* title. */
* it, but no supported release ships poll.js yet). `active()` below is called unguarded: this
* caller runs from the visibilitychange listener at module eval (:69-81), before CONTRACT_FNS ever
* checks anything, so what backs it is the floor, not the boot contract — `L.Poll.active` is
* unconditional in every 24.10+ luci.js. Read blind, a renamed `timer` field would make LuCI's tick
* look like a view's — cleared on the next navigation, every poll on every later page silently
* dead. So a missing field is a reason to do nothing, once, loudly: `active()` says whether the
* tick is running, and `timer` is deleted by `stop()`, so an absent field is the ordinary "nothing
* to protect" case. The anomaly worth reporting is the pair disagreeing — a tick running while the
* id it runs on has no name we know. */
/* -> the tick's id; null when LuCI is not polling; false when the two cannot be told apart, which
* every caller reads as "leave every interval alone" */
function pollTickId() {
if (!L.Poll) {
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
+ 'updating for this luci-base.');
return false;
}
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
const running = L.Poll.active();
if (running && L.Poll.timer == null) {
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
@@ -138,6 +129,13 @@ function clearViewIntervals() {
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
}
/* The "Refreshing"/"Paused" pill must not outlive the poll it reports on — see the `poll-stop`
* listener below, which is its only caller. */
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on the poll-status teardown', e); }
}
/* one line per document: this runs on every navigation, and a router that cannot read L.Poll
* cannot read it on the next click either */
let _pollWarned = false;
@@ -198,8 +196,7 @@ function flushUciCache() {
* is `uci.sections('wireless', 'wifi-device')`). So dropping those packages does not make
* network.js refetch them, it makes every consumer read an EMPTY config until the next full
* load: Channel Analysis with no band tabs, Network -> Switch with no VLAN sections, both
* correct again after F5. tools/spa-parity.mjs reproduces it; tools/upstream-contract.mjs
* notices if the list of three moves.
* correct again after F5.
*
* navigate() waits for the refill, because a cached module resolves within a microtask and the
* view would read the cache we just emptied. Only when network.js is really in the document.
@@ -328,17 +325,69 @@ function watchSession() {
* which also takes it out of the live tree. Public API only; no reaching into `dom.registry`. */
function discard(el) {
try {
const dom = window.L ? window.L.dom : null;
if (!dom || typeof dom.content !== 'function') { el.remove(); return; }
const bin = document.createElement('div');
bin.appendChild(el);
dom.content(bin, null);
window.L.dom.content(bin, null);
}
catch (e) {
el.remove();
}
}
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
*
* Some apps' render() appends tooltip/modal nodes to document.body on every visit (one leaves 7)
* and nothing here sweeps them, so the document is spent, as with an invasive foreign sheet. The
* recorder is header.ut's first <body> <script>, filling window.__fsBodyAdds; this file only reads it. */
/* -> true if `el` is a node the theme must treat as stray body litter: not the theme's own chrome,
* not something stock LuCI parks there itself, not a node type that can never paint. Pure: no DOM
* writes, no window.__fsBodyAdds read. */
function strayBodyNode(el) {
if (!el || el.nodeType !== 1) return false;
/* never rendered on their own, wherever a script parks them */
switch (el.nodeName) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
/* ours: the chrome mark, or an fs-* id/class. All of these
* parent straight to <body> and must never be read as litter: this file's own #fs-nav-progress,
* fs-search.js's #fs-search-ov (which also carries the mark), and the geometry/colour probes
* fs-chrome.js and fs-appearance.js each park on <body> once and never remove — an fs-* id is
* what tells them from a foreign app's own unmarked nodes below. */
/* read through `dataset`, not a literal `hasAttribute()` call: the build counts every
* single-quoted spelling of the chrome mark's name as a JS-built chrome root, a false positive
* for code that only READS the mark on somebody else's node. camelCase avoids that spelling. */
if (el.dataset && el.dataset.fsChrome !== undefined) return false;
if (el.id && el.id.indexOf('fs-') === 0) return false;
for (const c of el.classList) if (c.indexOf('fs-') === 0) return false;
/* stock LuCI's own ui.js parents these to <body> at its own __init__ and keeps them for the life
* of the document: #modal_overlay (showModal's host) and the shared .cbi-tooltip */
if (el.id === 'modal_overlay') return false;
if (el.classList.contains('cbi-tooltip')) return false;
/* ui.js's handleDownload() appends a hidden `<a download>`, clicks it once and revokes its object
* URL, but never removes the element itself. Every app that downloads the same way (wireguard,
* filemanager, snmpd, banip/adblock's feed export) calls a.remove() of its own accord and is
* pruned by bodyLittered() below before ever reaching this function. */
if (el.nodeName === 'A' && el.hasAttribute('download') && el.style.display === 'none') return false;
return true;
}
/* -> true iff the document holds a stray body node right now. A recording only ever grows — the
* recorder cannot know a node was later removed or moved inside #view — so this is also the one
* place that prunes it: an entry whose element is no longer a direct child of <body> is dropped for
* good, or the list would grow for the life of the document and re-judge nodes nobody can reach. */
function bodyLittered() {
const adds = window.__fsBodyAdds;
if (!adds || !adds.length) return false;
let littered = false;
for (let i = adds.length - 1; i >= 0; i--) {
if (!document.body || adds[i].parentNode !== document.body) { adds.splice(i, 1); continue; }
if (strayBodyNode(adds[i])) littered = true;
}
return littered;
}
let _wired = false;
/* the pathname whose view is currently rendered; popstate compares against it to tell a real
* navigation from a fragment change */
@@ -438,7 +487,7 @@ function restoreScroll(pos, gen) {
* their own RPCs. The engine then clamps the scroller BACK to whatever height exists NOW, firing
* an ordinary `scroll` event that looks exactly like a reader's, and it lands before this tick has
* ever written anything (`wroteWin`/`wroteMain` still -1), so the "our own write coming back"
* check above cannot catch it either. Measured live (owrt2512b @1440, `/admin/status/overview` <-
* check above cannot catch it either. Measured live (1440px wide, `/admin/status/overview` <-
* package-manager, Back): the UA restores `window.scrollY` to the parked 2684 in the same tick
* `popstate` fires, `commitStage()` leaves the document ~900px tall for one frame, and the next
* native `scroll` event reports `y=0` a whole 5 s before this function's own deadline — cancelling
@@ -469,7 +518,7 @@ function restoreScroll(pos, gen) {
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
@@ -774,11 +823,7 @@ function commitStage(stage, contentHost) {
if (contentHost) contentHost.setAttribute('data-page', page);
}
const nodes = Array.from(stage.view.childNodes);
const dom = window.L ? window.L.dom : null;
if (live && dom && typeof dom.content === 'function')
dom.content(live, nodes);
else if (live)
live.replaceChildren(...nodes);
window.L.dom.content(live, nodes);
dropStage(stage);
}
@@ -792,7 +837,7 @@ function commitStage(stage, contentHost) {
* with the transition and 213 ms without it, and the swap itself landed at 3,728 ms against 206 ms.
* The reader spends that time looking at the page they navigated away from, under the new URL —
* which is what a report of "the section is not where I expect it, F5 fixes it" looks like from the
* outside (issue #42), F5 being a full load and starting no transition.
* outside, F5 being a full load and starting no transition.
*
* A deadline was tried and does not hold: `skipTransition()` runs the update callback at once
* (3-5 ms on WebKit, Chromium and Firefox alike), but it has to be called from a timer, and the
@@ -892,6 +937,10 @@ function navigate(pathname, push, kbd) {
* only exit leaving both pages correct is a real navigation (fs-sheets.js) */
if (sheets.documentPoisoned()) return false;
/* …and a page that parked its own nodes on <body> is spent the same way: see
* strayBodyNode()/bodyLittered() above. */
if (bodyLittered()) return false;
/* …and a document whose session has died is spent the same way: the only page it can render
* correctly is the login form, and only a real navigation gets there (watchSession) */
if (_expired) return false;
@@ -952,8 +1001,8 @@ function navigate(pathname, push, kbd) {
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* Run every registered navigation callback. The seam is inverted on purpose: a registrant calls
* onNavigate() and the router names nobody, so an optional module that is not installed cannot
* be a DependencyError taking out the whole chrome.
* onNavigate() and the router names nobody, so the router keeps no static dependency
* (a DependencyError would take out the whole chrome).
*
* The RESOLVED segments are passed in, because this runs before L.env is re-pointed below and a
* callback reading L.env.dispatchpath would record the page being left.
@@ -1002,7 +1051,7 @@ function navigate(pathname, push, kbd) {
/* A full load starts at the top and the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. Both scrollers are reset, since which one scrolls depends
* on the layout — in the sidebar layout the window does not scroll, `.fs-shell` being 100dvh
* with `.fs-main` owning overflow-y (issue #7) — and scrollTo on the other is a no-op.
* with `.fs-main` owning overflow-y — and scrollTo on the other is a no-op.
*
* The WRITE is at commitStage now, not here — see there. `_rest` is forgotten here regardless:
* it is the reference fs-fit tells a reader-caused scroll from an engine's clamp with, and the
@@ -1095,6 +1144,11 @@ function navigate(pathname, push, kbd) {
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
/* The "Refreshing"/"Paused" pill's own teardown rides this `stop()`'s `poll-stop` event —
* see the listener below, not a call here. A page can also empty the queue on its own, with
* no navigation at all (`L.Poll.remove()`, e.g. `luci-mod-status`'s graphs.js on unload,
* `luci-app-banip`'s log view), and `stop()` dispatches the same event for that call too;
* a call placed only here would miss it. */
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own
@@ -1249,9 +1303,8 @@ function bootDocumentIsOurs() {
* Every module here is written against parts of somebody else's code that were never an API:
* `L.Poll` is a deprecated alias, `L.dom.content` and `ui.instantiateView` are what `view.ut`
* happens to use, `Request.addInterceptor` is how the session probe hears a 403.
* tools/upstream-contract.mjs asks whether they still BEHAVE as assumed, but only against the two
* userlands this repo owns. On a router carrying a luci-base that moved, the first anyone learns of
* it is a click that opens nothing.
* Existence is not behaviour: a name can survive and act differently. On a router carrying a
* luci-base that moved, the first anyone learns of it is a click that opens nothing.
*
* So existence is checked at boot, once, and a missing name turns the router OFF rather than
* on-and-broken: the page is then the server-dispatched MPA the theme was before the router
@@ -1379,7 +1432,7 @@ function wireRouter() {
/* A fragment change is not a navigation. Chrome fires `popstate` for a same-document
* fragment nav, so an `<a href="#">` inside a view — a common idiom for in-page controls —
* arrives here as if Back had been pressed, and re-running navigate() re-instantiates the
* view and wipes the state the click just set (issue #3). The view changed only if the PATH
* view and wipes the state the click just set. The view changed only if the PATH
* changed. */
if (window.location.pathname === _curPath)
return;
@@ -1401,24 +1454,21 @@ function wireRouter() {
});
}
/* ---- the poll indicator must not outlive the poll ----
/* ---- the poll-status pill must not outlive the poll it reports on ----
*
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop` and never
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). That is invisible on a full
* load, since Poll.start() dispatches `poll-start` only for a non-empty queue — but this router
* flushes the queue and calls stop() on every navigation, so walking from a polled page to an
* unpolled one leaves a "Paused" pill reporting on a poll that does not exist. The pill exists iff
* there is something to poll. Registered at module eval, i.e. after luci.js's own listener, so this
* runs second. */
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop` and never hides it. The
* queue is emptied by this router's navigate() teardown and also by a view calling `L.Poll.remove()`
* mid-page; both reach `L.Poll.stop()`, so one `poll-stop` listener covers both. The hide runs one
* microtask later: luci.js registers its own `poll-stop` listener (which shows "Paused" with no
* click handler) after an async chain while this one registers at eval, so the order is not
* deterministic, and hiding inline could remove the span before luci.js re-created it. The queue
* length is read at that later moment, so a queue the incoming page has already refilled is left alone. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
queueMicrotask(hidePollIndicatorIfEmpty);
});
/* At module eval, like the listener above: the session can die during the first view's own data
* calls, before anything has called wire(), and an interceptor registered later never sees it. */
/* At module eval: the session can die during the first view's own data calls, before anything has
* called wire(), and an interceptor registered later never sees it. */
watchSession();
/* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
@@ -1452,8 +1502,7 @@ function wireVisibility() {
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (they run before L.env is re-pointed). The registry is inverted on purpose: a registrant calls in
* and the router names nobody, so it cannot grow a static dependency on a module that may not be
* installed. */
* and the router names nobody, so it cannot grow a static dependency on one. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
@@ -1462,11 +1511,6 @@ return baseclass.extend({
wire: wireRouter,
wireVisibility,
onNavigate,
/* exported for tests/router-contract.test.mjs (no tests ship in the package), where it is driven
* against a hand-broken `L`: the one way to see the off branch */
/* likewise out-of-package: interval-pause.test.mjs drives the navigation sweep around a
* visibilitychange and session-expiry.test.mjs reads the verdict the interceptors reached.
* navigate() is the real caller of the first and `_expired` gates the second. */
/* fs-search warms its recents and the arrow-key-highlighted result, neither of which the
* pointer/focus triggers above can see. The edge points search -> router, because the router
* must keep no dependency on the palette. */
@@ -99,48 +99,6 @@ function index() {
return _index;
}
/* ---- extra sources -------------------------------------------------------
*
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
* is not its own (see keyOf).
*
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
* first gesture and most sessions never make it; a package that had to `require` it to register
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
*
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
* — and the stamp below is what rebuilds the pool when it does. */
const _sources = [];
let _pool = null, _stamp = -1;
function globalSources() {
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
}
function addSource(fn) {
_sources.push(fn);
_pool = null;
}
function refresh() {
_pool = null;
}
function pool() {
const all = _sources.concat(globalSources());
const stamp = all.length + (window.__fsSearchGen || 0);
if (_pool && stamp === _stamp) return _pool;
_stamp = stamp;
_pool = all.reduce((rows, fn) => {
try { return rows.concat(fn() || []); }
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
}, index().slice());
return _pool;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
@@ -160,7 +118,7 @@ function search(q, limit) {
if (!toks.length) return [];
const hits = [];
for (const e of pool()) {
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
@@ -179,31 +137,21 @@ function search(q, limit) {
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the KEY is stored, never the title — the title is resolved through the pool on every
* render, so it follows the UI language and a row whose package went away drops out instead of
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
* heading. */
* Only the PATH is stored, never the title — the title is resolved through the index on every
* render, so it follows the UI language and a page an ACL change hides drops out instead of
* lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also
* writes; the two modules stay decoupled (see there), so the string is the only thing shared. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
function keyOf(e) {
return e.key || e.path;
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
* only "System" in the list — the page path is all either row carries. */
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
const byPath = new Map(index().map((e) => [ e.path, e ]));
return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
@@ -311,16 +259,8 @@ function build() {
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere.
*
* `onTake` is how a row from an extra source finishes the job the href cannot: a
* section row's href can only reach the PAGE, so the source that produced it opens the
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
* Enter synthesises this very click. */
a.addEventListener('click', () => {
close(false);
if (typeof e.onTake === 'function') e.onTake();
});
* no focus return, the user is going elsewhere */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
@@ -421,7 +361,5 @@ function openPalette() {
}
return baseclass.extend({
open: openPalette,
/* the seam an optional package registers through; see addSource() */
addSource, refresh
open: openPalette
});
@@ -5,6 +5,7 @@
'require fs-fit as fit';
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
'require fs-chrome as chrome';
'require fs-widgets as widgets';
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
@@ -19,14 +20,14 @@
function readChoices(sel) {
const choices = {};
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
for (const o of sel.options) choices[o.value] = o.textContent;
return choices;
}
/* cheap identity of the option list, to detect a script rebuilding it
* (select.replaceChildren, dependency-driven re-population, …) */
function choicesKey(sel) {
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
}
/* Undo enhance(): drop the widget, unhide the select and cut every listener enhance() installed.
@@ -36,8 +37,7 @@ function choicesKey(sel) {
* anonymous listener. */
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
if (sel._fsNode && sel._fsNode.parentNode)
sel._fsNode.parentNode.removeChild(sel._fsNode);
sel._fsNode?.remove();
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
@@ -132,7 +132,7 @@ function enhance(sel) {
sel._fsAbort = ac;
/* after the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
sel.parentNode.insertBefore(node, sel.nextSibling);
sel.after(node);
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
let syncing = false;
@@ -184,11 +184,12 @@ function enhance(sel) {
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
* PASS rather than fixed at load, and fs-fit.js watches that class. */
const ROOTS = [ '#view', '#modal_overlay' ];
const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
const foreignTables = () => inRoots('.table:not(.cbi-section-table)') + ', ' +
inRoots('table:not(.table):not(.cbi-section-table)');
/* The fourth header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
* `<thead>` for the parser to imply and none of LuCI's class names.
@@ -331,8 +332,8 @@ function labelCells(t, head) {
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a
* race with every one of them — the role is written UNCONDITIONALLY, matching what a plain
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
* `display` rule on one of these classes with no role to match is what it is written to catch.
* the moment any of those rules land. A `display` rule on one of these classes with no role to
* match is a defect.
*
* The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
@@ -341,6 +342,9 @@ function labelCells(t, head) {
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
/* These two names and shapes are read by regex, so a `display` rule on any of these classes/tags
* is checked against a matching role — keep both names, and each entry a `'.class'` or `'tag'`
* string literal, even though roleTables() below reads them as one list. */
const TABLE_ROLE_CLASSES = [
[ '.table', 'table' ],
[ '.thead', 'rowgroup' ],
@@ -356,23 +360,12 @@ const TABLE_ROLE_TAGS = [
[ 'tfoot', 'rowgroup' ],
];
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
* no DOM and wakes no attribute observer. Not imported for one line — this file has none of that
* module's other exports to justify the dependency. */
function setRole(el, role) {
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
}
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written
* here changes nothing about that, since a role is inert on a box the engine never generates. */
function roleTables() {
TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
});
}
@@ -388,7 +381,7 @@ function roleTables() {
* NOT be measured: its rows hold widgets, and a widget bakes in the width of the layout it was laid
* out in, so un-collapsing it to read it changes what is read — the firewall zone table then
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
const stackables = () => inRoots('.table.fs-dt', liveRoots());
const stackables = () => inRoots('.table.fs-dt');
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
* decision takes on trust rather than measuring. A four-column table of short values still fits at
@@ -441,7 +434,7 @@ function markBreakColumn(t, rows, col) {
}
/* Rung 3. The widest column that can be shredded: never the first, which is the row's identity
* (issue #36 is that column being starved), and never a `nowrap`/`pre` one, where `overflow-wrap`
* (that column being starved is a known fault), and never a `nowrap`/`pre` one, where `overflow-wrap`
* is inert so the mark would buy a layout and no width.
*
* With honest floors nothing is starved, so an overflowing table has every column at its own
@@ -527,7 +520,7 @@ function fitTables() {
/* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
* reads layout while `scrolling()` is true. */
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)') : stackables();
document.querySelectorAll(sel).forEach((t) => {
/* While the reader scrolls this pass only WRITES: every layout read here forces a
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
@@ -694,7 +687,7 @@ function fitTables() {
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
* width that matters is the room rather than the viewport, so this measures instead of naming a
* page. */
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots());
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
@@ -795,7 +788,7 @@ function fitScrollables() {
function unpinActionColumn() {
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
if (fit.scrolling()) { fit.deferMeasurement(); return; }
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* Claim upstream's resize hook, which under SPA navigation is a leak:
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
@@ -864,7 +857,10 @@ const TYPEAHEAD_RESET_MS = 1000;
let _taBuf = '', _taTimer = null, _taLast = null;
function typeaheadItems(sb) {
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
/* sb is always `.cbi-dropdown[open]` here, and stock ui.js sets that attribute and adds
* `ul.dropdown` in the same openDropdown() call (openwrt/luci ui.js), so the two never
* disagree — verified on openwrt-24.10. */
const ul = sb.querySelector('ul.dropdown');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
@@ -923,10 +919,7 @@ function wireTypeahead() {
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
const inst = dom.findClassInstance(sb);
if (inst && typeof inst.setFocus === 'function')
inst.setFocus(sb, hit, true);
else
hit.focus();
if (inst) inst.setFocus(sb, hit, true);
ev.preventDefault();
ev.stopPropagation();
@@ -301,8 +301,7 @@ function documentCarries(path) {
* original is deliberately not one. */
function documentPoisoned() {
const names = themeNames();
return Array.prototype.some.call(
document.querySelectorAll(VIEW_SHEETS),
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
(el) => outlivesPage(el)
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}
@@ -313,7 +312,7 @@ function documentPoisoned() {
* already applying. Every footstrap rule lives in a @layer and an unlayered normal declaration
* beats a layered one at any specificity, so a third-party `* { margin: 0; padding: 0 }` owns the
* chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
* layers are what hand it the win (issue #8).
* layers are what hand it the win.
*
* So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
* only same-layer arbitrates by specificity. Measured on the real cascade:
@@ -417,7 +416,7 @@ function fenceImported(styleEl, names, until) {
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
* them. Serialised only to compare, never re-parsed. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is NOT its sheet ----
*
@@ -536,13 +535,12 @@ function ownerKey() {
return (_ownerHint !== null) ? _ownerHint : currentKey();
}
/* L.env.dispatchpath (ctx.path) is stamped by luci-base's own header.ut before any theme script
* runs, on every page including blank_page — verified on openwrt-24.10, no location.pathname
* fallback needed. */
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
if (dp && dp.length) return appKey(dp);
/* no env to read (a document that never got the bootstrap): the URL is all there is */
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
return appKey(p ? p.split('/') : []);
return appKey(L.env.dispatchpath);
}
/* Both halves, for the reason silence() gives: el.disabled is the element's flag, el.sheet.disabled
@@ -1,11 +1,11 @@
'use strict';
'require baseclass';
/* The installed version, shown on the Appearance page with no network call; which version is
/* The installed version, shown on the Appearance tab with no network call; which version is
* available is the package manager's question.
*
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
* The version stamp is written into the literal below BY FILE NAME, so it must
* stay in this file. An unstamped checkout
* stays 'dev'. */
const FS_VERSION = '0.0.0-dev';
@@ -1,9 +1,10 @@
'use strict';
'require baseclass';
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
* the colour control behind the Appearance page's colour axes. Nothing here knows what it is used
* for, so the menu and the Appearance page share it without requiring each other. */
/* The theme's UI primitives: the inline-SVG wrapper and two small idioms every module that draws a
* control on its own repeats — an idempotent attribute write and Enter/Space activation for an
* element with no native key handling. Nothing here knows what it is used for, so the menu, the
* chrome and the Overview grid share it without requiring each other. */
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
@@ -15,49 +16,27 @@ function svgIcon(body, cls) {
+ body + '</svg>';
}
/* ---- disclosure primitives, shared by the menu ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The trigger selector stays a parameter. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
* layout's trigger (the menu's `:scope > a`). */
function setOpen(li, on, linkSel) {
li.classList.toggle('open', on);
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. `value === null` removes the attribute instead of writing the string
* "null". Three call sites (menu-footstrap-common.js's meter annotator, fs-chrome.js's indicator
* pills, fs-overview.js's card disclosure) carried this same body separately; held here once. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
* to answer both. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
/* Enter/Space activation for an element with no native key handling of its own — a `<span>`
* `role="button"`, unlike an `<a role="button">`, gets neither key for free. Calling `activate()`
* rather than `el.click()` directly lets a caller forward the activation to a different element
* (fs-overview.js's card header activates the pill it labels). */
function wireActivate(el, activate) {
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
link.click();
});
}
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
* target is not an Element and has no closest(). The throw would kill this listener for
* the rest of the session. */
if (active() && !ev.target.closest?.(opts.inside))
opts.close();
});
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape' || !active()) return;
const open = document.querySelector(opts.open);
if (!open) return;
const trigger = open.querySelector(opts.trigger);
opts.close();
trigger?.focus();
activate();
});
}
@@ -68,7 +47,6 @@ function wireDismiss(opts) {
return baseclass.extend({
svgIcon,
setOpen,
wireSpaceKey,
wireDismiss
syncAttr,
wireActivate
});
@@ -7,6 +7,7 @@
'require fs-router as router';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
'require fs-widgets as widgets';
/* Page modules: `fs-overview` (Status -> Overview) adds to a stock page rather than owning a
* route, so it is required only there. A `require` pragma would make it a hard dependency —
@@ -52,27 +53,19 @@ function wirePageModules() {
* and the warm pass that uses it, so both live here, in the file every page already loads.
*
* The palette reads the list back from localStorage when it opens, so the two halves share the key
* and nothing else. */
* and nothing else — fs-search.js owns the "recently visited" feature, so RECENT_KEY and RECENT_MAX
* below have to match its constants of the same name exactly. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
const RECENT_WARM = 5;
/* A key is a menu path, or a page path plus the heading of a section inside it
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
* source that produced the row can build that half. Exported for exactly that: the writer stays
* one function, or the two halves would drift on the cap and the de-duplication. */
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
function remember(key) {
if (typeof key !== 'string' || !key) return;
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
}
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
function pageOf(key) {
const h = key.indexOf('#');
return h < 0 ? key : key.slice(0, h);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
@@ -87,10 +80,9 @@ function pageOf(key) {
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
* warm it once — the module chain is the page's. */
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
@@ -100,8 +92,8 @@ function warmRecent() {
}
function wireSearch() {
/* #fs-search-btn is emitted whenever this module loads (search.ut, !blank_page) */
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
const RT = window.L;
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
@@ -139,23 +131,6 @@ function wireSearch() {
});
}
/* ---- optional companion packages ----
*
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
* chrome requires each one after everything below is wired — a plugin registers itself through the
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
* nobody. A plugin that throws costs only itself.
*
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
function loadPlugins() {
const RT = window.L;
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
names.forEach((name) => {
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
});
}
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
* plain percentage is kept anyway so every meter on the page reads the same way. */
@@ -191,17 +166,6 @@ const FS_METER_INVERTED_DANGER = 100 - FS_METER_DANGER;
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
* the attribute instead of writing the string "null". */
function fsSyncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
@@ -237,8 +201,7 @@ function findProgressbarLabel(pg) {
* cannot pick a wrong number out of a title carrying several. An empty title or one with no
* percentage in any of the three shapes yields null, on purpose — nothing to annotate. `%d` is the
* unclamped percentage, so the result can still read past 100 or under 0 and is clamped by the
* caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
* caller, the same way `window.progressbar` clamps its own `level`, below. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title) || (/^(-?\d+)%/).exec(title);
@@ -256,14 +219,14 @@ function annotateMeter(pg) {
const pc = parseMeterPercent(title);
if (pc == null) return;
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
fsSyncAttr(pg, 'role', 'progressbar');
fsSyncAttr(pg, 'aria-valuemin', '0');
fsSyncAttr(pg, 'aria-valuemax', '100');
fsSyncAttr(pg, 'aria-valuenow', String(level));
fsSyncAttr(pg, 'aria-valuetext', title);
widgets.syncAttr(pg, 'role', 'progressbar');
widgets.syncAttr(pg, 'aria-valuemin', '0');
widgets.syncAttr(pg, 'aria-valuemax', '100');
widgets.syncAttr(pg, 'aria-valuenow', String(level));
widgets.syncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
const name = label ? label.textContent.trim() : '';
fsSyncAttr(pg, 'aria-label', label ? name : null);
widgets.syncAttr(pg, 'aria-label', label ? name : null);
/* Polarity: an unrecognised bar (a third-party app's own meter included — see the Sets above)
* keeps the plain fill-based rule, on purpose. A wrong red is worse than a missing colour, but a
* MISSING colour on the common case — a used-based fill, which is what an app most often draws —
@@ -278,12 +241,12 @@ function annotateMeter(pg) {
} else {
dataLevel = level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null);
}
fsSyncAttr(pg, 'data-fs-level', dataLevel);
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
}
/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
* one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */
* one); `widgets.syncAttr` above makes a re-run over an unchanged bar a no-op read. */
function annotateMeters(root) {
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
@@ -366,7 +329,7 @@ ensureOverviewHelpers();
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
* fs-widgets the inline-SVG wrapper, an idempotent attribute write, Enter/Space activation
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
* fs-router the SPA client router (docs/spa-router.md)
* fs-sheets the guard against a view's injected CSS repainting every later page
@@ -382,17 +345,10 @@ ensureOverviewHelpers();
* halves (fs-menutree, fs-prefs) are separate modules. */
return baseclass.extend({
/* the seam a companion package writes its own rows into the recents list through; see
* remember() for what a key is */
remember,
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
* the meters a stock Status -> Overview include draws with its own local progressbar() */
annotateMeters,
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
* split directly rather than through a live `.cbi-progressbar[title]` walk */
init(renderMainMenu) {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
@@ -422,9 +378,6 @@ return baseclass.extend({
wirePageModules();
router.wire();
router.wireVisibility();
/* last: a plugin registers against the parts above, and a broken one must not be able
* to take the chrome with it */
loadPlugins();
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* tab together — so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
@@ -7,8 +7,8 @@
'require menu-footstrap-common as common';
/* The theme's one menu renderer: a vertical #topmenu that the CSS also turns into the top bar and
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
* the rail flyouts — same markup, no second renderer. The disclosure primitives live in this file,
* the auto-collapse preference comes from fs-prefs; the rest of the chrome is bootstrapped by
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
* common.init — a callback, not an override, since a required LuCI module is a singleton and
* cannot be subclassed. Spec: docs/chrome.md */
@@ -53,10 +53,33 @@ function flyoutMode() {
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- disclosure primitives ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The one consumer of all three is this file, so they live here rather than behind a
* `linkSel`/`opts` parameter in fs-widgets.js — TRIGGER above is the only trigger selector either
* ever sees. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. */
function setOpen(li, on) {
li.classList.toggle('open', on);
li.querySelector(TRIGGER)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
* to answer both — contrast fs-widgets.js's wireActivate(), written for an element with neither. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ') return;
ev.preventDefault();
link.click();
});
}
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. The rail flies panels out
@@ -66,7 +89,7 @@ const EDGE_GAP = 8;
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped. */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}
@@ -97,10 +120,6 @@ function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
}
function setOpen(li, on) {
widgets.setOpen(li, on, TRIGGER);
}
function closeFlyouts(except) {
document.querySelectorAll(OPEN_LI).forEach((o) => {
if (o !== except) setOpen(o, false);
@@ -139,7 +158,8 @@ function renderMainMenu(tree, url, level) {
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
const children = ui.menu.getChildren(tree);
if (!ul || children.length === 0 || level > 1)
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
if (children.length === 0 || level > 1)
return E([]);
/* dispatchpath = [mode, section, subsection, …]; sections sit at
@@ -239,7 +259,7 @@ function renderMainMenu(tree, url, level) {
saveOpenSections();
});
widgets.wireSpaceKey(link);
wireSpaceKey(link);
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType, since a touch
@@ -265,13 +285,23 @@ return baseclass.extend({
common.init(renderMainMenu);
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it
* `.open` means unfolded accordion, which must not fold on a click elsewhere */
widgets.wireDismiss({
when: flyoutMode,
inside: '#topmenu > li.has-sub',
open: OPEN_LI,
trigger: TRIGGER,
close: () => closeFlyouts()
* `.open` means unfolded accordion, which must not fold on a click elsewhere. WCAG 2.2
* SC 1.4.13 also requires a hover/focus panel to be dismissible from the keyboard, with
* focus handed back to the trigger. */
document.addEventListener('click', (ev) => {
/* `closest?.`: a document-level listener sees any dispatched click, including one
* whose target is not an Element and has no closest(). The throw would kill this
* listener for the rest of the session. */
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
closeFlyouts();
});
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape' || !flyoutMode()) return;
const open = document.querySelector(OPEN_LI);
if (!open) return;
const trigger = open.querySelector(TRIGGER);
closeFlyouts();
trigger?.focus();
});
/* Entering flyout mode folds everything, or a section left open as an accordion