@aria-framework/theme 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -113
- package/build.js +133 -173
- package/dist/ThemeContext.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/mobile/ThemeContext.tsx +59 -59
- package/mobile/index.ts +5 -5
- package/package.json +3 -1
- package/src/base.css +63 -0
- package/src/components/sidebar.css +116 -0
- package/tokens.json +9 -1
- package/web/sidebar.js +115 -0
- package/web/theme.css +136 -12
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
/**
|
|
17
|
+
/** @aria-framework/theme (mobile) — barrel re-export for React Native consumers. */
|
|
18
18
|
__exportStar(require("./tokens"), exports);
|
|
19
19
|
__exportStar(require("./fonts"), exports);
|
|
20
20
|
__exportStar(require("./ThemeContext"), exports);
|
package/mobile/ThemeContext.tsx
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Theme provider — resolves the active palette (Ops Light / Ops Dark) from the
|
|
3
|
-
* user's preference (light | dark | system) and the OS appearance, and persists
|
|
4
|
-
* the choice in AsyncStorage. Screens read `const { colors } = useTheme()`.
|
|
5
|
-
*/
|
|
6
|
-
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
-
import { Appearance } from "react-native";
|
|
8
|
-
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
9
|
-
import { palettes, type Palette } from "./tokens";
|
|
10
|
-
|
|
11
|
-
export type ThemePref = "light" | "dark" | "system";
|
|
12
|
-
type Scheme = "light" | "dark";
|
|
13
|
-
|
|
14
|
-
/** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
|
|
15
|
-
* so multiple
|
|
16
|
-
const DEFAULT_KEY = "field-ops.theme";
|
|
17
|
-
|
|
18
|
-
interface ThemeState {
|
|
19
|
-
colors: Palette;
|
|
20
|
-
scheme: Scheme; // resolved (system → actual OS scheme)
|
|
21
|
-
pref: ThemePref; // user's stored choice
|
|
22
|
-
setPref: (p: ThemePref) => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const Ctx = createContext<ThemeState | undefined>(undefined);
|
|
26
|
-
|
|
27
|
-
export function ThemeProvider({ children, storageKey = DEFAULT_KEY }: { children: React.ReactNode; storageKey?: string }) {
|
|
28
|
-
const [pref, setPrefState] = useState<ThemePref>("system");
|
|
29
|
-
const [sysScheme, setSysScheme] = useState<Scheme>(Appearance.getColorScheme() === "dark" ? "dark" : "light");
|
|
30
|
-
|
|
31
|
-
// OS appearance listener — independent of storageKey, so subscribe exactly once.
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
const sub = Appearance.addChangeListener(({ colorScheme }) => setSysScheme(colorScheme === "dark" ? "dark" : "light"));
|
|
34
|
-
return () => sub.remove();
|
|
35
|
-
}, []);
|
|
36
|
-
|
|
37
|
-
// Load the persisted preference (re-reads only if the storage key changes).
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
AsyncStorage.getItem(storageKey).then((v) => {
|
|
40
|
-
if (v === "light" || v === "dark" || v === "system") setPrefState(v);
|
|
41
|
-
}).catch(() => {});
|
|
42
|
-
}, [storageKey]);
|
|
43
|
-
|
|
44
|
-
const setPref = useCallback((p: ThemePref) => {
|
|
45
|
-
setPrefState(p);
|
|
46
|
-
AsyncStorage.setItem(storageKey, p).catch(() => {});
|
|
47
|
-
}, [storageKey]);
|
|
48
|
-
|
|
49
|
-
const scheme: Scheme = pref === "system" ? sysScheme : pref;
|
|
50
|
-
const value = useMemo<ThemeState>(() => ({ colors: palettes[scheme], scheme, pref, setPref }), [scheme, pref, setPref]);
|
|
51
|
-
|
|
52
|
-
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function useTheme(): ThemeState {
|
|
56
|
-
const v = useContext(Ctx);
|
|
57
|
-
if (!v) throw new Error("useTheme must be used within ThemeProvider");
|
|
58
|
-
return v;
|
|
59
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Theme provider — resolves the active palette (Ops Light / Ops Dark) from the
|
|
3
|
+
* user's preference (light | dark | system) and the OS appearance, and persists
|
|
4
|
+
* the choice in AsyncStorage. Screens read `const { colors } = useTheme()`.
|
|
5
|
+
*/
|
|
6
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { Appearance } from "react-native";
|
|
8
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
9
|
+
import { palettes, type Palette } from "./tokens";
|
|
10
|
+
|
|
11
|
+
export type ThemePref = "light" | "dark" | "system";
|
|
12
|
+
type Scheme = "light" | "dark";
|
|
13
|
+
|
|
14
|
+
/** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
|
|
15
|
+
* so multiple aria-framework apps on a device don't share one preference. */
|
|
16
|
+
const DEFAULT_KEY = "field-ops.theme";
|
|
17
|
+
|
|
18
|
+
interface ThemeState {
|
|
19
|
+
colors: Palette;
|
|
20
|
+
scheme: Scheme; // resolved (system → actual OS scheme)
|
|
21
|
+
pref: ThemePref; // user's stored choice
|
|
22
|
+
setPref: (p: ThemePref) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const Ctx = createContext<ThemeState | undefined>(undefined);
|
|
26
|
+
|
|
27
|
+
export function ThemeProvider({ children, storageKey = DEFAULT_KEY }: { children: React.ReactNode; storageKey?: string }) {
|
|
28
|
+
const [pref, setPrefState] = useState<ThemePref>("system");
|
|
29
|
+
const [sysScheme, setSysScheme] = useState<Scheme>(Appearance.getColorScheme() === "dark" ? "dark" : "light");
|
|
30
|
+
|
|
31
|
+
// OS appearance listener — independent of storageKey, so subscribe exactly once.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const sub = Appearance.addChangeListener(({ colorScheme }) => setSysScheme(colorScheme === "dark" ? "dark" : "light"));
|
|
34
|
+
return () => sub.remove();
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
// Load the persisted preference (re-reads only if the storage key changes).
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
AsyncStorage.getItem(storageKey).then((v) => {
|
|
40
|
+
if (v === "light" || v === "dark" || v === "system") setPrefState(v);
|
|
41
|
+
}).catch(() => {});
|
|
42
|
+
}, [storageKey]);
|
|
43
|
+
|
|
44
|
+
const setPref = useCallback((p: ThemePref) => {
|
|
45
|
+
setPrefState(p);
|
|
46
|
+
AsyncStorage.setItem(storageKey, p).catch(() => {});
|
|
47
|
+
}, [storageKey]);
|
|
48
|
+
|
|
49
|
+
const scheme: Scheme = pref === "system" ? sysScheme : pref;
|
|
50
|
+
const value = useMemo<ThemeState>(() => ({ colors: palettes[scheme], scheme, pref, setPref }), [scheme, pref, setPref]);
|
|
51
|
+
|
|
52
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useTheme(): ThemeState {
|
|
56
|
+
const v = useContext(Ctx);
|
|
57
|
+
if (!v) throw new Error("useTheme must be used within ThemeProvider");
|
|
58
|
+
return v;
|
|
59
|
+
}
|
package/mobile/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export * from "./tokens";
|
|
3
|
-
export * from "./fonts";
|
|
4
|
-
export * from "./ThemeContext";
|
|
5
|
-
export * from "./ui";
|
|
1
|
+
/** @aria-framework/theme (mobile) — barrel re-export for React Native consumers. */
|
|
2
|
+
export * from "./tokens";
|
|
3
|
+
export * from "./fonts";
|
|
4
|
+
export * from "./ThemeContext";
|
|
5
|
+
export * from "./ui";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Aria App Framework — theme module. Field Ops Console design system: tokens.json single source of record + a generator that emits a Bootstrap 5.3 web theme (theme.css + self-hosted fonts) and a React Native palette/provider/primitives. Light + dark.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"web",
|
|
18
18
|
"dist",
|
|
19
19
|
"mobile",
|
|
20
|
+
"src",
|
|
20
21
|
"tokens.json",
|
|
21
22
|
"build.js",
|
|
22
23
|
"tsconfig.json",
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
],
|
|
25
26
|
"exports": {
|
|
26
27
|
"./theme.css": "./web/theme.css",
|
|
28
|
+
"./sidebar.js": "./web/sidebar.js",
|
|
27
29
|
"./mobile": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
28
30
|
"./tokens": { "types": "./dist/tokens.d.ts", "default": "./dist/tokens.js" },
|
|
29
31
|
"./fonts": { "types": "./dist/fonts.d.ts", "default": "./dist/fonts.js" },
|
package/src/base.css
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* ── Base — static layer (fonts, Bootstrap mapping, edge-clarity, buttons, a11y).
|
|
2
|
+
Concatenated by build.js AFTER the generated token blocks and BEFORE the
|
|
3
|
+
component partials in src/components/. Token values come from tokens.json. */
|
|
4
|
+
|
|
5
|
+
/* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
|
|
6
|
+
@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
|
|
7
|
+
src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
|
|
8
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
|
|
9
|
+
src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
|
|
10
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
|
|
11
|
+
src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
|
|
12
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
|
|
13
|
+
src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
|
|
14
|
+
|
|
15
|
+
/* ── Map tokens onto Bootstrap (load AFTER bootstrap.min.css so these win) ───*/
|
|
16
|
+
:root{
|
|
17
|
+
--bs-body-font-family:var(--font-sans);
|
|
18
|
+
--bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
|
|
19
|
+
--bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-deep);
|
|
20
|
+
--bs-body-bg:var(--paper); --bs-body-color:var(--text);
|
|
21
|
+
--bs-border-color:var(--line);
|
|
22
|
+
}
|
|
23
|
+
[data-bs-theme="dark"]{
|
|
24
|
+
--bs-primary:var(--brand); --bs-primary-rgb:var(--brand-rgb);
|
|
25
|
+
--bs-link-color:var(--brand); --bs-link-color-rgb:var(--brand-rgb); --bs-link-hover-color:var(--brand-bright);
|
|
26
|
+
--bs-body-bg:var(--paper); --bs-body-color:var(--text);
|
|
27
|
+
--bs-border-color:var(--line);
|
|
28
|
+
}
|
|
29
|
+
body{font-family:var(--font-sans)}
|
|
30
|
+
.mono,.tracking-id,code,kbd,.font-monospace{font-family:var(--font-mono)}
|
|
31
|
+
|
|
32
|
+
/* ── Edge clarity — faint --line for internal dividers; --line-strong + shadow
|
|
33
|
+
for container edges, so cards/inputs read as raised on white/paper. ────────*/
|
|
34
|
+
.card{ border-color:var(--line-strong); box-shadow:var(--shadow); }
|
|
35
|
+
.form-control,.form-select{ border-color:var(--line-strong); }
|
|
36
|
+
.input-group-text{ border-color:var(--line-strong); }
|
|
37
|
+
.table{ --bs-table-border-color:var(--line); }
|
|
38
|
+
.list-group{ --bs-list-group-border-color:var(--line-strong); }
|
|
39
|
+
|
|
40
|
+
/* ── Brand buttons (component vars compile into Bootstrap; set explicitly) ───*/
|
|
41
|
+
.btn-primary{
|
|
42
|
+
--bs-btn-bg:var(--brand); --bs-btn-border-color:var(--brand);
|
|
43
|
+
--bs-btn-hover-bg:var(--brand-deep); --bs-btn-hover-border-color:var(--brand-deep);
|
|
44
|
+
--bs-btn-active-bg:var(--brand-deep); --bs-btn-active-border-color:var(--brand-deep);
|
|
45
|
+
--bs-btn-disabled-bg:var(--brand); --bs-btn-disabled-border-color:var(--brand);
|
|
46
|
+
}
|
|
47
|
+
.btn-outline-primary{
|
|
48
|
+
--bs-btn-color:var(--brand); --bs-btn-border-color:var(--brand);
|
|
49
|
+
--bs-btn-hover-bg:var(--brand); --bs-btn-hover-border-color:var(--brand);
|
|
50
|
+
--bs-btn-active-bg:var(--brand); --bs-btn-active-border-color:var(--brand);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ── Accessibility ──────────────────────────────────────────────────────────*/
|
|
54
|
+
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
|
|
55
|
+
textarea:focus-visible, [tabindex]:focus-visible {
|
|
56
|
+
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 6px;
|
|
57
|
+
}
|
|
58
|
+
@media (prefers-reduced-motion: reduce) {
|
|
59
|
+
*, *::before, *::after {
|
|
60
|
+
animation-duration: .001ms !important; animation-iteration-count: 1 !important;
|
|
61
|
+
transition-duration: .001ms !important; scroll-behavior: auto !important;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* ── Component: sidebar rail (.s-side) ───────────────────────────────────────
|
|
2
|
+
Dark nav rail, mobile-first: off-canvas overlay by default; the ≥768px block
|
|
3
|
+
puts it back in flow as the desktop rail with a collapsible icon-rail mode.
|
|
4
|
+
Behavior lives in sidebar.js (same package, served beside theme.css).
|
|
5
|
+
|
|
6
|
+
MARKUP CONTRACT (the app renders this; see package README):
|
|
7
|
+
#wrapper > .s-side + #page-content-wrapper
|
|
8
|
+
.s-side > .s-brand (.s-brand-link > .s-mark + span(.s-name+.s-ver), .s-collapse[data-sidebar-toggle])
|
|
9
|
+
+ .s-nav (.s-grp headings, .s-link[.active] items: <i> icon + <span> label)
|
|
10
|
+
+ .s-me (.s-av, .s-me-who > b+span, .s-out-form > .s-out)
|
|
11
|
+
Topbar hamburger (mobile): .topbar-toggle[data-sidebar-toggle]
|
|
12
|
+
State classes on <html>: .sidebar-open (mobile overlay), .sidebar-collapsed
|
|
13
|
+
(desktop rail; persist + pre-apply before paint to avoid a flash). */
|
|
14
|
+
|
|
15
|
+
/* Body itself doesn't scroll; sidebar nav + content scroll independently. */
|
|
16
|
+
#wrapper { height: 100vh; overflow: hidden; }
|
|
17
|
+
#page-content-wrapper { height: 100vh; overflow-y: auto; }
|
|
18
|
+
|
|
19
|
+
/* Mobile-first: the sidebar is an off-canvas overlay by default; the
|
|
20
|
+
@media(min-width:768px) block below puts it back in flow as the desktop rail.
|
|
21
|
+
One breakpoint, mobile-default + desktop-override — the two layers meet with
|
|
22
|
+
no gap. sidebar.js reads the current mode from CSS (backdrop display). */
|
|
23
|
+
.s-side{
|
|
24
|
+
position:fixed; top:0; left:0; z-index:1050;
|
|
25
|
+
min-width:236px; width:236px; height:100vh; display:flex; flex-direction:column; overflow:hidden;
|
|
26
|
+
background:linear-gradient(180deg, var(--side-bg1), var(--side-bg2));
|
|
27
|
+
color:var(--side-tx); border-right:1px solid var(--side-line);
|
|
28
|
+
transform:translateX(-100%); transition:transform .2s ease;
|
|
29
|
+
}
|
|
30
|
+
html.sidebar-open .s-side{ transform:translateX(0); box-shadow:0 24px 60px rgba(0,0,0,.5); }
|
|
31
|
+
#sidebar-backdrop{ position:fixed; inset:0; z-index:1040; background:rgba(0,0,0,.45); opacity:0; visibility:hidden; transition:opacity .2s ease; }
|
|
32
|
+
html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
|
|
33
|
+
.s-brand{ display:flex; align-items:center; padding:16px 14px 12px; }
|
|
34
|
+
.s-brand-link{ display:flex; align-items:center; gap:11px; text-decoration:none; }
|
|
35
|
+
.s-mark{
|
|
36
|
+
width:34px; height:34px; border-radius:9px; flex:0 0 34px; display:grid; place-items:center;
|
|
37
|
+
background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
|
|
38
|
+
box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
|
|
39
|
+
}
|
|
40
|
+
.s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
|
|
41
|
+
.s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
|
|
42
|
+
|
|
43
|
+
.s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
|
|
44
|
+
.s-nav::-webkit-scrollbar{ width:7px; } .s-nav::-webkit-scrollbar-thumb{ background:var(--side-card); border-radius:8px; }
|
|
45
|
+
.s-grp{
|
|
46
|
+
font-family:var(--font-mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase;
|
|
47
|
+
color:var(--side-muted); padding:16px 12px 7px;
|
|
48
|
+
}
|
|
49
|
+
.s-link{
|
|
50
|
+
display:flex; align-items:center; gap:10px; padding:8px 11px; border-radius:9px; position:relative;
|
|
51
|
+
color:var(--side-tx); text-decoration:none; font-weight:500; font-size:14px; transition:background .14s, color .14s;
|
|
52
|
+
white-space:nowrap;
|
|
53
|
+
}
|
|
54
|
+
.s-link i{ font-size:16px; width:18px; text-align:center; opacity:.85; }
|
|
55
|
+
.s-link:hover{ background:var(--side-card); color:#fff; }
|
|
56
|
+
.s-link.active{ background:var(--side-active); color:#fff; }
|
|
57
|
+
.s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--brand-bright); }
|
|
58
|
+
.s-link.active i{ opacity:1; color:var(--brand-bright); }
|
|
59
|
+
|
|
60
|
+
.s-me{
|
|
61
|
+
margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
|
|
62
|
+
background:var(--side-card); border:1px solid var(--side-line);
|
|
63
|
+
}
|
|
64
|
+
.s-av{
|
|
65
|
+
width:34px; height:34px; border-radius:8px; flex:0 0 34px; display:grid; place-items:center;
|
|
66
|
+
background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
|
|
67
|
+
}
|
|
68
|
+
.s-me-who{ min-width:0; line-height:1.25; flex:1; }
|
|
69
|
+
.s-me-who b{ display:block; color:#fff; font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
70
|
+
.s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
|
|
71
|
+
.s-out-form{ margin:0; }
|
|
72
|
+
.s-out{ border:0; background:transparent; color:var(--side-muted); font-size:16px; line-height:1; padding:4px; cursor:pointer; }
|
|
73
|
+
.s-out:hover{ color:#fff; }
|
|
74
|
+
|
|
75
|
+
/* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
|
|
76
|
+
topbar hamburger (shown on mobile, opens the overlay). */
|
|
77
|
+
.s-collapse{
|
|
78
|
+
flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
|
|
79
|
+
background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
|
|
80
|
+
}
|
|
81
|
+
.s-collapse:hover{ background:var(--side-card); color:#fff; }
|
|
82
|
+
.s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
|
|
83
|
+
html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
|
|
84
|
+
|
|
85
|
+
/* Brighter keyboard-focus ring inside the dark rail — the base a11y rule uses
|
|
86
|
+
var(--brand), which is near-invisible against the rail background. */
|
|
87
|
+
.s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--brand-bright); }
|
|
88
|
+
|
|
89
|
+
.topbar-toggle{
|
|
90
|
+
flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);
|
|
91
|
+
background:var(--surface); color:var(--text-soft); display:grid; place-items:center;
|
|
92
|
+
font-size:18px; cursor:pointer; padding:0;
|
|
93
|
+
}
|
|
94
|
+
.topbar-toggle:hover{ color:var(--text); }
|
|
95
|
+
|
|
96
|
+
/* ── Desktop (≥768px): in-flow sidebar, header chevron, collapsed icon rail ──*/
|
|
97
|
+
@media (min-width: 768px){
|
|
98
|
+
.s-side{ position:static; transform:none; box-shadow:none; transition:width .18s ease, min-width .18s ease; }
|
|
99
|
+
#sidebar-backdrop{ display:none; }
|
|
100
|
+
.topbar-toggle{ display:none; } /* desktop uses the header chevron */
|
|
101
|
+
.s-collapse{ display:grid; } /* chevron shown only on desktop */
|
|
102
|
+
|
|
103
|
+
html.sidebar-collapsed .s-side{ min-width:64px; width:64px; }
|
|
104
|
+
/* Stack the logo + collapse chevron so the chevron stays reachable to expand. */
|
|
105
|
+
html.sidebar-collapsed .s-brand{ flex-direction:column; gap:9px; padding:16px 0 12px; }
|
|
106
|
+
html.sidebar-collapsed .s-brand-link{ justify-content:center; gap:0; }
|
|
107
|
+
html.sidebar-collapsed .s-brand-link > span:last-child{ display:none; }
|
|
108
|
+
html.sidebar-collapsed .s-collapse{ margin-left:0; }
|
|
109
|
+
html.sidebar-collapsed .s-nav{ padding:4px 8px 12px; }
|
|
110
|
+
html.sidebar-collapsed .s-grp{ display:none; }
|
|
111
|
+
html.sidebar-collapsed .s-link{ justify-content:center; gap:0; padding-left:0; padding-right:0; }
|
|
112
|
+
html.sidebar-collapsed .s-link span{ display:none; }
|
|
113
|
+
html.sidebar-collapsed .s-link.active::before{ display:none; }
|
|
114
|
+
html.sidebar-collapsed .s-me{ flex-direction:column; gap:8px; padding:10px 6px; margin:10px 8px 14px; }
|
|
115
|
+
html.sidebar-collapsed .s-me-who{ display:none; }
|
|
116
|
+
}
|
package/tokens.json
CHANGED
|
@@ -32,5 +32,13 @@
|
|
|
32
32
|
},
|
|
33
33
|
"status": { "new": "#2563eb", "open": "#0e8fa8", "pending": "#d9870b", "resolved": "#0e9e6e", "closed": "#7a848b" },
|
|
34
34
|
"priority": { "low": "#9aa6ae", "medium": "#2563eb", "high": "#ea580c", "critical": "#dc2626" },
|
|
35
|
-
"space": { "xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24 }
|
|
35
|
+
"space": { "xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24 },
|
|
36
|
+
"$sidebarComment": "Sidebar rail tokens (web sidebar component) — scheme-INDEPENDENT: the rail is dark in both light and dark modes, so these emit into :root only, as --side-*.",
|
|
37
|
+
"sidebar": {
|
|
38
|
+
"bg1": "#0E1417", "bg2": "#141C21",
|
|
39
|
+
"tx": "#BAC3C8", "muted": "#69767D",
|
|
40
|
+
"active": "rgba(21,184,166,.13)",
|
|
41
|
+
"line": "rgba(255,255,255,.07)",
|
|
42
|
+
"card": "rgba(255,255,255,.045)"
|
|
43
|
+
}
|
|
36
44
|
}
|
package/web/sidebar.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @aria-framework/theme — sidebar.js: collapse / expand the app sidebar rail.
|
|
3
|
+
*
|
|
4
|
+
* Desktop: the sidebar-header chevron collapses the sidebar to an icon rail and
|
|
5
|
+
* persists the choice (apps should pre-apply the class before paint — read the
|
|
6
|
+
* same key in an early-loaded script — so a collapsed sidebar doesn't flash
|
|
7
|
+
* open on load). Mobile: the topbar hamburger slides the off-canvas sidebar in
|
|
8
|
+
* as an overlay (transient).
|
|
9
|
+
*
|
|
10
|
+
* Storage key is configurable per app via the script tag:
|
|
11
|
+
* <script src="/theme/sidebar.js" data-storage-key="myapp-sidebar-collapsed"></script>
|
|
12
|
+
* (default "aria-sidebar-collapsed"). Pass your app's historical key to keep
|
|
13
|
+
* users' persisted collapsed state across a migration to this package.
|
|
14
|
+
*
|
|
15
|
+
* Safe to include anywhere (head or body, with or without defer): init waits
|
|
16
|
+
* for DOMContentLoaded when needed, and clicks are handled by delegation, so
|
|
17
|
+
* toggles/nav links added after load still work.
|
|
18
|
+
*
|
|
19
|
+
* Which layout we're in is read FROM CSS (the backdrop's computed display), so
|
|
20
|
+
* the breakpoint lives only in the stylesheet — JS never restates 768px and the
|
|
21
|
+
* two layers can't drift. CSP-safe: external, self-hosted, no inline script.
|
|
22
|
+
*/
|
|
23
|
+
(function () {
|
|
24
|
+
// Double-include guard: a second copy (e.g. two layout partials both adding
|
|
25
|
+
// the script tag) would register a second delegated listener, making every
|
|
26
|
+
// toggle fire twice — a silent, error-free dead sidebar. First copy wins.
|
|
27
|
+
if (window.__ariaSidebar) return;
|
|
28
|
+
window.__ariaSidebar = true;
|
|
29
|
+
|
|
30
|
+
// currentScript is only set during initial execution — capture before deferring.
|
|
31
|
+
var script = document.currentScript;
|
|
32
|
+
var KEY = (script && script.dataset && script.dataset.storageKey) || 'aria-sidebar-collapsed';
|
|
33
|
+
|
|
34
|
+
function init() {
|
|
35
|
+
var root = document.documentElement;
|
|
36
|
+
|
|
37
|
+
// Backdrop for the mobile overlay (created once). Its computed display is
|
|
38
|
+
// our single source of truth for "mobile layout" — `block` only inside the
|
|
39
|
+
// mobile media query, `none` on desktop.
|
|
40
|
+
var backdrop = document.getElementById('sidebar-backdrop');
|
|
41
|
+
if (!backdrop) {
|
|
42
|
+
backdrop = document.createElement('div');
|
|
43
|
+
backdrop.id = 'sidebar-backdrop';
|
|
44
|
+
document.body.appendChild(backdrop);
|
|
45
|
+
}
|
|
46
|
+
function isMobile() { return getComputedStyle(backdrop).display !== 'none'; }
|
|
47
|
+
|
|
48
|
+
var content = document.getElementById('page-content-wrapper'); // inert while overlay is open
|
|
49
|
+
var lastFocus = null;
|
|
50
|
+
|
|
51
|
+
// Reflect state to assistive tech: the header chevron announces rail
|
|
52
|
+
// expanded/collapsed; the topbar hamburger announces overlay open/closed.
|
|
53
|
+
// Buttons are queried live so late-added toggles stay in sync.
|
|
54
|
+
function syncAria() {
|
|
55
|
+
var collapsed = root.classList.contains('sidebar-collapsed');
|
|
56
|
+
var open = root.classList.contains('sidebar-open');
|
|
57
|
+
document.querySelectorAll('[data-sidebar-toggle]').forEach(function (b) {
|
|
58
|
+
b.setAttribute('aria-expanded', String(b.classList.contains('s-collapse') ? !collapsed : open));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `opener` is the button that opened the overlay — restore focus to it on
|
|
63
|
+
// close (activeElement isn't reliable: some browsers don't focus a button
|
|
64
|
+
// on mouse click). Make the background inert so Tab can't escape the overlay.
|
|
65
|
+
function openOverlay(opener) {
|
|
66
|
+
lastFocus = opener || document.activeElement;
|
|
67
|
+
root.classList.add('sidebar-open');
|
|
68
|
+
if (content) content.inert = true;
|
|
69
|
+
var first = document.querySelector('.s-nav .s-link');
|
|
70
|
+
if (first) first.focus(); // move focus into the opened menu
|
|
71
|
+
syncAria();
|
|
72
|
+
}
|
|
73
|
+
function closeOverlay(restoreFocus) {
|
|
74
|
+
root.classList.remove('sidebar-open');
|
|
75
|
+
if (content) content.inert = false; // un-inert before restoring focus into it
|
|
76
|
+
if (restoreFocus && lastFocus && lastFocus.focus) lastFocus.focus();
|
|
77
|
+
lastFocus = null;
|
|
78
|
+
syncAria();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toggle(opener) {
|
|
82
|
+
if (isMobile()) {
|
|
83
|
+
if (root.classList.contains('sidebar-open')) closeOverlay(true); else openOverlay(opener);
|
|
84
|
+
} else {
|
|
85
|
+
var collapsed = root.classList.toggle('sidebar-collapsed');
|
|
86
|
+
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* storage off */ }
|
|
87
|
+
syncAria();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Delegated clicks: toggles work wherever/whenever they're added; tapping a
|
|
92
|
+
// nav link on mobile closes the overlay (it also navigates — no focus to
|
|
93
|
+
// restore). Backdrop tap and Escape close it too.
|
|
94
|
+
document.addEventListener('click', function (e) {
|
|
95
|
+
var t = e.target.closest && e.target.closest('[data-sidebar-toggle]');
|
|
96
|
+
if (t) { toggle(t); return; }
|
|
97
|
+
if (e.target === backdrop) { closeOverlay(true); return; }
|
|
98
|
+
if (isMobile() && e.target.closest && e.target.closest('.s-nav .s-link')) closeOverlay(false);
|
|
99
|
+
});
|
|
100
|
+
document.addEventListener('keydown', function (e) {
|
|
101
|
+
if (e.key === 'Escape' && root.classList.contains('sidebar-open')) closeOverlay(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Growing back to desktop clears any transient mobile-open state. Cheap
|
|
105
|
+
// guard first, so getComputedStyle only runs while an overlay is open.
|
|
106
|
+
window.addEventListener('resize', function () {
|
|
107
|
+
if (root.classList.contains('sidebar-open') && !isMobile()) closeOverlay(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
syncAria(); // initial state (the app's early boot script may pre-set sidebar-collapsed)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|
114
|
+
else init();
|
|
115
|
+
})();
|
package/web/theme.css
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
/* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */
|
|
2
|
-
/*
|
|
2
|
+
/* @aria-framework/theme — Field Ops Console design system (web).
|
|
3
3
|
Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
|
|
4
|
-
bootstrap.min.css →
|
|
4
|
+
bootstrap.min.css → theme.css → app.css
|
|
5
5
|
Serve web/ same-origin (e.g. mount at /theme) so fonts satisfy CSP font-src 'self'. */
|
|
6
6
|
|
|
7
|
-
/* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
|
|
8
|
-
@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
|
|
9
|
-
src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
|
|
10
|
-
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
|
|
11
|
-
src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
|
|
12
|
-
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
|
|
13
|
-
src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
|
|
14
|
-
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
|
|
15
|
-
src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
|
|
16
|
-
|
|
17
7
|
/* ── Design tokens — Ops Light in :root; Ops Dark keyed on [data-bs-theme] ───*/
|
|
18
8
|
:root{
|
|
19
9
|
--font-sans:'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
@@ -24,6 +14,9 @@
|
|
|
24
14
|
--text:#18222A; --text-soft:#5C6770; --text-faint:#929BA1;
|
|
25
15
|
--shadow:0 1px 2px rgba(16,24,28,.05), 0 4px 14px -6px rgba(16,24,28,.12);
|
|
26
16
|
--shadow-lg:0 18px 50px -18px rgba(16,24,28,.30);
|
|
17
|
+
/* Sidebar rail (scheme-independent: dark in both modes) */
|
|
18
|
+
--side-bg1:#0E1417; --side-bg2:#141C21; --side-tx:#BAC3C8; --side-muted:#69767D;
|
|
19
|
+
--side-active:rgba(21,184,166,.13); --side-line:rgba(255,255,255,.07); --side-card:rgba(255,255,255,.045);
|
|
27
20
|
}
|
|
28
21
|
[data-bs-theme="dark"]{
|
|
29
22
|
--brand:#16B5A3; --brand-deep:#0E7A6E; --brand-bright:#2FE0CC; --brand-rgb:22,181,163;
|
|
@@ -34,6 +27,20 @@
|
|
|
34
27
|
--shadow-lg:0 24px 60px -18px rgba(0,0,0,.8);
|
|
35
28
|
}
|
|
36
29
|
|
|
30
|
+
/* ── Base — static layer (fonts, Bootstrap mapping, edge-clarity, buttons, a11y).
|
|
31
|
+
Concatenated by build.js AFTER the generated token blocks and BEFORE the
|
|
32
|
+
component partials in src/components/. Token values come from tokens.json. */
|
|
33
|
+
|
|
34
|
+
/* ── Self-hosted fonts (relative URLs → resolve under the mount, no CDN) ─────*/
|
|
35
|
+
@font-face{font-family:'Hanken Grotesk';font-style:normal;font-weight:100 900;font-display:swap;
|
|
36
|
+
src:url('./fonts/hanken-grotesk-wght.woff2') format('woff2')}
|
|
37
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;
|
|
38
|
+
src:url('./fonts/ibm-plex-mono-400.woff2') format('woff2')}
|
|
39
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
|
|
40
|
+
src:url('./fonts/ibm-plex-mono-500.woff2') format('woff2')}
|
|
41
|
+
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
|
|
42
|
+
src:url('./fonts/ibm-plex-mono-600.woff2') format('woff2')}
|
|
43
|
+
|
|
37
44
|
/* ── Map tokens onto Bootstrap (load AFTER bootstrap.min.css so these win) ───*/
|
|
38
45
|
:root{
|
|
39
46
|
--bs-body-font-family:var(--font-sans);
|
|
@@ -83,3 +90,120 @@ textarea:focus-visible, [tabindex]:focus-visible {
|
|
|
83
90
|
transition-duration: .001ms !important; scroll-behavior: auto !important;
|
|
84
91
|
}
|
|
85
92
|
}
|
|
93
|
+
|
|
94
|
+
/* ── Component: sidebar rail (.s-side) ───────────────────────────────────────
|
|
95
|
+
Dark nav rail, mobile-first: off-canvas overlay by default; the ≥768px block
|
|
96
|
+
puts it back in flow as the desktop rail with a collapsible icon-rail mode.
|
|
97
|
+
Behavior lives in sidebar.js (same package, served beside theme.css).
|
|
98
|
+
|
|
99
|
+
MARKUP CONTRACT (the app renders this; see package README):
|
|
100
|
+
#wrapper > .s-side + #page-content-wrapper
|
|
101
|
+
.s-side > .s-brand (.s-brand-link > .s-mark + span(.s-name+.s-ver), .s-collapse[data-sidebar-toggle])
|
|
102
|
+
+ .s-nav (.s-grp headings, .s-link[.active] items: <i> icon + <span> label)
|
|
103
|
+
+ .s-me (.s-av, .s-me-who > b+span, .s-out-form > .s-out)
|
|
104
|
+
Topbar hamburger (mobile): .topbar-toggle[data-sidebar-toggle]
|
|
105
|
+
State classes on <html>: .sidebar-open (mobile overlay), .sidebar-collapsed
|
|
106
|
+
(desktop rail; persist + pre-apply before paint to avoid a flash). */
|
|
107
|
+
|
|
108
|
+
/* Body itself doesn't scroll; sidebar nav + content scroll independently. */
|
|
109
|
+
#wrapper { height: 100vh; overflow: hidden; }
|
|
110
|
+
#page-content-wrapper { height: 100vh; overflow-y: auto; }
|
|
111
|
+
|
|
112
|
+
/* Mobile-first: the sidebar is an off-canvas overlay by default; the
|
|
113
|
+
@media(min-width:768px) block below puts it back in flow as the desktop rail.
|
|
114
|
+
One breakpoint, mobile-default + desktop-override — the two layers meet with
|
|
115
|
+
no gap. sidebar.js reads the current mode from CSS (backdrop display). */
|
|
116
|
+
.s-side{
|
|
117
|
+
position:fixed; top:0; left:0; z-index:1050;
|
|
118
|
+
min-width:236px; width:236px; height:100vh; display:flex; flex-direction:column; overflow:hidden;
|
|
119
|
+
background:linear-gradient(180deg, var(--side-bg1), var(--side-bg2));
|
|
120
|
+
color:var(--side-tx); border-right:1px solid var(--side-line);
|
|
121
|
+
transform:translateX(-100%); transition:transform .2s ease;
|
|
122
|
+
}
|
|
123
|
+
html.sidebar-open .s-side{ transform:translateX(0); box-shadow:0 24px 60px rgba(0,0,0,.5); }
|
|
124
|
+
#sidebar-backdrop{ position:fixed; inset:0; z-index:1040; background:rgba(0,0,0,.45); opacity:0; visibility:hidden; transition:opacity .2s ease; }
|
|
125
|
+
html.sidebar-open #sidebar-backdrop{ opacity:1; visibility:visible; }
|
|
126
|
+
.s-brand{ display:flex; align-items:center; padding:16px 14px 12px; }
|
|
127
|
+
.s-brand-link{ display:flex; align-items:center; gap:11px; text-decoration:none; }
|
|
128
|
+
.s-mark{
|
|
129
|
+
width:34px; height:34px; border-radius:9px; flex:0 0 34px; display:grid; place-items:center;
|
|
130
|
+
background:linear-gradient(140deg, var(--brand-bright), var(--brand-deep)); color:#fff; font-weight:800; font-size:17px;
|
|
131
|
+
box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 6px 16px -6px var(--brand);
|
|
132
|
+
}
|
|
133
|
+
.s-name{ display:block; color:#fff; font-weight:700; font-size:16px; letter-spacing:-.01em; line-height:1; }
|
|
134
|
+
.s-ver{ display:block; font-family:var(--font-mono); font-size:11px; color:var(--side-muted); margin-top:3px; }
|
|
135
|
+
|
|
136
|
+
.s-nav{ flex:1; overflow-y:auto; padding:4px 12px 12px; }
|
|
137
|
+
.s-nav::-webkit-scrollbar{ width:7px; } .s-nav::-webkit-scrollbar-thumb{ background:var(--side-card); border-radius:8px; }
|
|
138
|
+
.s-grp{
|
|
139
|
+
font-family:var(--font-mono); font-size:10.5px; letter-spacing:.14em; text-transform:uppercase;
|
|
140
|
+
color:var(--side-muted); padding:16px 12px 7px;
|
|
141
|
+
}
|
|
142
|
+
.s-link{
|
|
143
|
+
display:flex; align-items:center; gap:10px; padding:8px 11px; border-radius:9px; position:relative;
|
|
144
|
+
color:var(--side-tx); text-decoration:none; font-weight:500; font-size:14px; transition:background .14s, color .14s;
|
|
145
|
+
white-space:nowrap;
|
|
146
|
+
}
|
|
147
|
+
.s-link i{ font-size:16px; width:18px; text-align:center; opacity:.85; }
|
|
148
|
+
.s-link:hover{ background:var(--side-card); color:#fff; }
|
|
149
|
+
.s-link.active{ background:var(--side-active); color:#fff; }
|
|
150
|
+
.s-link.active::before{ content:""; position:absolute; left:-12px; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:var(--brand-bright); }
|
|
151
|
+
.s-link.active i{ opacity:1; color:var(--brand-bright); }
|
|
152
|
+
|
|
153
|
+
.s-me{
|
|
154
|
+
margin:10px 12px 14px; padding:10px 12px; border-radius:11px; display:flex; align-items:center; gap:10px;
|
|
155
|
+
background:var(--side-card); border:1px solid var(--side-line);
|
|
156
|
+
}
|
|
157
|
+
.s-av{
|
|
158
|
+
width:34px; height:34px; border-radius:8px; flex:0 0 34px; display:grid; place-items:center;
|
|
159
|
+
background:linear-gradient(140deg, var(--brand), var(--brand-deep)); color:#fff; font-weight:700; font-size:13px;
|
|
160
|
+
}
|
|
161
|
+
.s-me-who{ min-width:0; line-height:1.25; flex:1; }
|
|
162
|
+
.s-me-who b{ display:block; color:#fff; font-size:13.5px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
163
|
+
.s-me-who span{ font-family:var(--font-mono); font-size:10.5px; color:var(--side-muted); text-transform:uppercase; letter-spacing:.06em; }
|
|
164
|
+
.s-out-form{ margin:0; }
|
|
165
|
+
.s-out{ border:0; background:transparent; color:var(--side-muted); font-size:16px; line-height:1; padding:4px; cursor:pointer; }
|
|
166
|
+
.s-out:hover{ color:#fff; }
|
|
167
|
+
|
|
168
|
+
/* Collapse toggle — the sidebar-header chevron (shown on desktop) and the
|
|
169
|
+
topbar hamburger (shown on mobile, opens the overlay). */
|
|
170
|
+
.s-collapse{
|
|
171
|
+
flex:0 0 auto; margin-left:auto; width:28px; height:28px; padding:0; border:0; cursor:pointer;
|
|
172
|
+
background:transparent; color:var(--side-muted); border-radius:7px; display:none; place-items:center; font-size:15px;
|
|
173
|
+
}
|
|
174
|
+
.s-collapse:hover{ background:var(--side-card); color:#fff; }
|
|
175
|
+
.s-collapse i{ display:block; transition:transform .18s ease; } /* rotate the <i> (centre origin) — no SVG-pivot bug */
|
|
176
|
+
html.sidebar-collapsed .s-collapse i{ transform:rotate(180deg); } /* points right = expand */
|
|
177
|
+
|
|
178
|
+
/* Brighter keyboard-focus ring inside the dark rail — the base a11y rule uses
|
|
179
|
+
var(--brand), which is near-invisible against the rail background. */
|
|
180
|
+
.s-link:focus-visible, .s-out:focus-visible, .s-brand-link:focus-visible, .s-collapse:focus-visible { outline-color: var(--brand-bright); }
|
|
181
|
+
|
|
182
|
+
.topbar-toggle{
|
|
183
|
+
flex:0 0 auto; width:38px; height:38px; border-radius:10px; border:1px solid var(--line);
|
|
184
|
+
background:var(--surface); color:var(--text-soft); display:grid; place-items:center;
|
|
185
|
+
font-size:18px; cursor:pointer; padding:0;
|
|
186
|
+
}
|
|
187
|
+
.topbar-toggle:hover{ color:var(--text); }
|
|
188
|
+
|
|
189
|
+
/* ── Desktop (≥768px): in-flow sidebar, header chevron, collapsed icon rail ──*/
|
|
190
|
+
@media (min-width: 768px){
|
|
191
|
+
.s-side{ position:static; transform:none; box-shadow:none; transition:width .18s ease, min-width .18s ease; }
|
|
192
|
+
#sidebar-backdrop{ display:none; }
|
|
193
|
+
.topbar-toggle{ display:none; } /* desktop uses the header chevron */
|
|
194
|
+
.s-collapse{ display:grid; } /* chevron shown only on desktop */
|
|
195
|
+
|
|
196
|
+
html.sidebar-collapsed .s-side{ min-width:64px; width:64px; }
|
|
197
|
+
/* Stack the logo + collapse chevron so the chevron stays reachable to expand. */
|
|
198
|
+
html.sidebar-collapsed .s-brand{ flex-direction:column; gap:9px; padding:16px 0 12px; }
|
|
199
|
+
html.sidebar-collapsed .s-brand-link{ justify-content:center; gap:0; }
|
|
200
|
+
html.sidebar-collapsed .s-brand-link > span:last-child{ display:none; }
|
|
201
|
+
html.sidebar-collapsed .s-collapse{ margin-left:0; }
|
|
202
|
+
html.sidebar-collapsed .s-nav{ padding:4px 8px 12px; }
|
|
203
|
+
html.sidebar-collapsed .s-grp{ display:none; }
|
|
204
|
+
html.sidebar-collapsed .s-link{ justify-content:center; gap:0; padding-left:0; padding-right:0; }
|
|
205
|
+
html.sidebar-collapsed .s-link span{ display:none; }
|
|
206
|
+
html.sidebar-collapsed .s-link.active::before{ display:none; }
|
|
207
|
+
html.sidebar-collapsed .s-me{ flex-direction:column; gap:8px; padding:10px 6px; margin:10px 8px 14px; }
|
|
208
|
+
html.sidebar-collapsed .s-me-who{ display:none; }
|
|
209
|
+
}
|