@aria-framework/theme 0.3.0 → 0.4.0
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 +161 -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 +109 -0
- package/web/theme.css +136 -12
package/README.md
CHANGED
|
@@ -1,113 +1,161 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
Shared **Field Ops Console** design system for apps on the Bootstrap 5.3 (web) +
|
|
4
|
-
Expo / React Native (mobile) stack. One source of record for the brand tokens,
|
|
5
|
-
the web `theme.css
|
|
6
|
-
Light + dark.
|
|
7
|
-
|
|
8
|
-
> **`tokens.json` is the single source of record.** A tiny generator (`build.js`)
|
|
9
|
-
> emits BOTH `web/theme.css` (CSS custom properties) and `mobile/tokens.ts` (the RN
|
|
10
|
-
> palette) from it, so the two platforms can never drift. Edit `tokens.json`, run
|
|
11
|
-
> `npm run build`, commit the regenerated outputs, and bump the package version
|
|
12
|
-
> (semver). Consuming apps pin a version and `npm update` to adopt.
|
|
13
|
-
|
|
14
|
-
## Editing tokens (the generator)
|
|
15
|
-
|
|
16
|
-
1. Edit values in **`tokens.json`** (palettes.light / palettes.dark, plus status /
|
|
17
|
-
priority / space). Hex is stored uppercase; `--brand-rgb` is derived automatically.
|
|
18
|
-
2. Run **`npm run build`** (`node build.js`). It regenerates:
|
|
19
|
-
- `web/theme.css` — the `:root` + `[data-bs-theme="dark"]` token blocks (the rest
|
|
20
|
-
of the file — Bootstrap mapping, edge-clarity, buttons, a11y — is static
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<link rel="stylesheet" href="/
|
|
52
|
-
<link rel="stylesheet" href="/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
>
|
|
102
|
-
|
|
103
|
-
>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
# @aria-framework/theme
|
|
2
|
+
|
|
3
|
+
Shared **Field Ops Console** design system for apps on the Bootstrap 5.3 (web) +
|
|
4
|
+
Expo / React Native (mobile) stack. One source of record for the brand tokens,
|
|
5
|
+
the web `theme.css` + shell components (sidebar), and the React Native palette /
|
|
6
|
+
theme provider / UI primitives. Light + dark.
|
|
7
|
+
|
|
8
|
+
> **`tokens.json` is the single source of record.** A tiny generator (`build.js`)
|
|
9
|
+
> emits BOTH `web/theme.css` (CSS custom properties) and `mobile/tokens.ts` (the RN
|
|
10
|
+
> palette) from it, so the two platforms can never drift. Edit `tokens.json`, run
|
|
11
|
+
> `npm run build`, commit the regenerated outputs, and bump the package version
|
|
12
|
+
> (semver). Consuming apps pin a version and `npm update` to adopt.
|
|
13
|
+
|
|
14
|
+
## Editing tokens (the generator)
|
|
15
|
+
|
|
16
|
+
1. Edit values in **`tokens.json`** (palettes.light / palettes.dark, plus status /
|
|
17
|
+
priority / space). Hex is stored uppercase; `--brand-rgb` is derived automatically.
|
|
18
|
+
2. Run **`npm run build`** (`node build.js`). It regenerates:
|
|
19
|
+
- `web/theme.css` — the `:root` + `[data-bs-theme="dark"]` token blocks (the rest
|
|
20
|
+
of the file — Bootstrap mapping, edge-clarity, buttons, a11y — is static
|
|
21
|
+
template), **plus every component partial in `src/components/*.css`** appended
|
|
22
|
+
in filename order.
|
|
23
|
+
- `mobile/tokens.ts` — `Palette`, `palettes`, `colors`, `status`, `priority`,
|
|
24
|
+
`space`, `shadow`.
|
|
25
|
+
3. **Do not hand-edit** `web/theme.css` or `mobile/tokens.ts` (both carry an
|
|
26
|
+
AUTO-GENERATED banner). The static base CSS (fonts/mapping/buttons/a11y) lives in
|
|
27
|
+
`src/base.css`; component CSS lives in `src/components/*.css` — build.js emits
|
|
28
|
+
generated tokens → base → components.
|
|
29
|
+
4. Commit the regenerated files (consumers don't run the generator) and bump the version.
|
|
30
|
+
|
|
31
|
+
> Note: the generator covers tokens. The RN `fonts.ts` / `ThemeContext.tsx` / `ui.tsx`
|
|
32
|
+
> are hand-written (they're code, not values). Consuming the package's RAW `.tsx` on
|
|
33
|
+
> mobile out-of-tree needs Metro `watchFolders` (and breaks `tsc` unless the package
|
|
34
|
+
> ships compiled JS+`.d.ts`) — see the Metro note below.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Published on the public npm registry:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install @aria-framework/theme
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Web (Bootstrap 5.3)
|
|
45
|
+
|
|
46
|
+
Load order in `<head>` matters — Bootstrap first, then this theme (so its `--bs-*`
|
|
47
|
+
overrides win), then your app-specific component CSS:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<link rel="stylesheet" href="/vendor/bootstrap.min.css">
|
|
51
|
+
<link rel="stylesheet" href="/theme/theme.css"> <!-- @aria-framework/theme -->
|
|
52
|
+
<link rel="stylesheet" href="/css/app.css"> <!-- your components -->
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Fonts + CSP.** `theme.css` ships self-hosted woff2 in `web/fonts/` and references
|
|
56
|
+
them with **relative** URLs (`./fonts/…`). Serve the package's `web/` folder
|
|
57
|
+
**same-origin** so they satisfy a strict `Content-Security-Policy: font-src 'self'`
|
|
58
|
+
(no CDN). With Express:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const themeDir = path.dirname(require.resolve('@aria-framework/theme/theme.css'));
|
|
62
|
+
app.use('/theme', express.static(themeDir)); // → /theme/theme.css + /theme/fonts/*
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Dark mode is driven by a single attribute: `<html data-bs-theme="dark">`.
|
|
66
|
+
|
|
67
|
+
### What's in `theme.css`
|
|
68
|
+
Design tokens (Ops Light `:root` + Ops Dark `[data-bs-theme="dark"]`), the
|
|
69
|
+
`--bs-*` mapping, `--shadow`/`--shadow-lg`, brand buttons (`.btn-primary`,
|
|
70
|
+
`.btn-outline-primary`), edge-clarity (cards/inputs use `--line-strong` + shadow;
|
|
71
|
+
tables keep the faint `--line` for row dividers), `.mono` helper, focus-visible
|
|
72
|
+
and reduced-motion — plus the **shell components** below. **Domain-specific styles
|
|
73
|
+
do not belong here** — keep dashboards, page layouts etc. in your app's own CSS.
|
|
74
|
+
Rule of thumb: if a second app would want it unchanged except for content, it
|
|
75
|
+
belongs here; if it knows about your domain, it stays in the app.
|
|
76
|
+
|
|
77
|
+
### Component: sidebar rail
|
|
78
|
+
|
|
79
|
+
A dark nav rail, identical in light + dark modes. Mobile-first: off-canvas
|
|
80
|
+
overlay under 768px (hamburger + backdrop + Esc/backdrop-tap close, focus
|
|
81
|
+
trapped via `inert`); an in-flow rail on desktop with a persisted collapse to a
|
|
82
|
+
64px icon rail. The package ships the **CSS** (in `theme.css`) and the
|
|
83
|
+
**behavior** (`sidebar.js`); the app ships the **markup** against this contract:
|
|
84
|
+
|
|
85
|
+
```html
|
|
86
|
+
<div id="wrapper">
|
|
87
|
+
<nav class="s-side">
|
|
88
|
+
<div class="s-brand">
|
|
89
|
+
<a class="s-brand-link" href="/">
|
|
90
|
+
<span class="s-mark">S</span>
|
|
91
|
+
<span><span class="s-name">MyApp</span><span class="s-ver">v1.0.0</span></span>
|
|
92
|
+
</a>
|
|
93
|
+
<button class="s-collapse" data-sidebar-toggle aria-label="Collapse sidebar"><i class="bi bi-chevron-double-left"></i></button>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="s-nav">
|
|
96
|
+
<div class="s-grp">Section</div>
|
|
97
|
+
<a class="s-link active" href="/x"><i class="bi bi-..."></i><span>Item</span></a>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="s-me">
|
|
100
|
+
<span class="s-av">AB</span>
|
|
101
|
+
<span class="s-me-who"><b>Ann Bee</b><span>ADMIN</span></span>
|
|
102
|
+
<form class="s-out-form" method="post" action="/logout"><button class="s-out" aria-label="Sign out"><i class="bi bi-box-arrow-right"></i></button></form>
|
|
103
|
+
</div>
|
|
104
|
+
</nav>
|
|
105
|
+
<div id="page-content-wrapper">
|
|
106
|
+
<!-- your topbar should include the mobile hamburger: -->
|
|
107
|
+
<button class="topbar-toggle" data-sidebar-toggle aria-label="Open menu"><i class="bi bi-list"></i></button>
|
|
108
|
+
...
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<script src="/theme/sidebar.js" data-storage-key="myapp-sidebar-collapsed"></script>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Notes:
|
|
115
|
+
- `data-storage-key` namespaces the persisted collapsed state per app (default
|
|
116
|
+
`aria-sidebar-collapsed`). Keep an app's historical key across migrations.
|
|
117
|
+
- To avoid a flash of the expanded rail on load, pre-apply the class in an early
|
|
118
|
+
script: `if (localStorage.getItem('<key>') === '1') document.documentElement.classList.add('sidebar-collapsed')`.
|
|
119
|
+
- State classes live on `<html>`: `.sidebar-open` (mobile overlay),
|
|
120
|
+
`.sidebar-collapsed` (desktop icon rail).
|
|
121
|
+
- Sidebar tokens are themeable in `tokens.json` under `"sidebar"` (emitted as
|
|
122
|
+
`--side-*`). Icons in the examples are Bootstrap Icons, but any icon font works.
|
|
123
|
+
|
|
124
|
+
## Mobile (Expo / React Native)
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { ThemeProvider, useTheme, Card, Btn, Field, palettes } from '@aria-framework/theme/mobile';
|
|
128
|
+
|
|
129
|
+
export default function App() {
|
|
130
|
+
return (
|
|
131
|
+
<ThemeProvider storageKey="myapp.theme">{/* namespace per app */}
|
|
132
|
+
<Screens />
|
|
133
|
+
</ThemeProvider>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Screen() {
|
|
138
|
+
const { colors, scheme } = useTheme();
|
|
139
|
+
return <Card><Text style={{ color: colors.text }}>Hello</Text></Card>;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Fonts: load `@expo-google-fonts/hanken-grotesk` + `@expo-google-fonts/ibm-plex-mono`
|
|
144
|
+
in your app entry; the family names in `mobile/fonts.ts` match those packages.
|
|
145
|
+
|
|
146
|
+
Peer deps (provided by the app): `react`, `react-native`,
|
|
147
|
+
`@react-native-async-storage/async-storage`.
|
|
148
|
+
|
|
149
|
+
> **Metro note:** consuming a `file:` dependency requires Metro to watch the
|
|
150
|
+
> package folder. Add it to `watchFolders` in `metro.config.js`, or use a published
|
|
151
|
+
> git version to avoid symlink/transpile quirks.
|
|
152
|
+
|
|
153
|
+
## Versioning
|
|
154
|
+
|
|
155
|
+
Token change → edit `tokens.json`, run `npm run build`, commit the regenerated
|
|
156
|
+
`web/theme.css` + `mobile/tokens.ts`, then bump the `version` in `package.json`.
|
|
157
|
+
|
|
158
|
+
- **v0.1.0** — initial extraction (refined light borders + defined elevation tokens),
|
|
159
|
+
hand-maintained web/mobile.
|
|
160
|
+
- **v0.2.0** — added `tokens.json` + `build.js` generator (single source of record;
|
|
161
|
+
web + mobile generated from one file). Unified the drifted dark `--line-strong`.
|
package/build.js
CHANGED
|
@@ -1,173 +1,133 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/*
|
|
3
|
-
* - web/theme.css (Bootstrap 5.3 token layer + static mapping/edge/buttons/a11y)
|
|
4
|
-
* - mobile/tokens.ts (RN Palette + palettes/colors/status/priority/space/shadow)
|
|
5
|
-
* Run: `node build.js`. The two outputs are committed artifacts (consumers don't
|
|
6
|
-
* need to run this); regenerate whenever tokens.json changes, then bump the version. */
|
|
7
|
-
const fs = require("fs");
|
|
8
|
-
const path = require("path");
|
|
9
|
-
const T = require("./tokens.json");
|
|
10
|
-
|
|
11
|
-
const GEN_CSS = "/* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */";
|
|
12
|
-
const GEN_TS = "// AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`.";
|
|
13
|
-
const FONT_SANS = `'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
|
14
|
-
const FONT_MONO = `'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
15
|
-
|
|
16
|
-
const hexToRgb = (hex) => {
|
|
17
|
-
const n = parseInt(hex.replace("#", ""), 16);
|
|
18
|
-
return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(",");
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
--
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/*
|
|
46
|
-
|
|
47
|
-
--
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* Field Ops Console — shared visual tokens (React Native side).
|
|
135
|
-
* \`palettes.light|dark\` are consumed by ThemeContext; \`colors\` is the light
|
|
136
|
-
* default kept for back-compat with screens that import it directly.
|
|
137
|
-
*/
|
|
138
|
-
export type Palette = {
|
|
139
|
-
bg: string; surface: string; surface2: string; border: string; lineStrong: string;
|
|
140
|
-
text: string; muted: string; textFaint: string;
|
|
141
|
-
primary: string; primaryText: string; brandDeep: string; brandBright: string;
|
|
142
|
-
danger: string; success: string; signal: string;
|
|
143
|
-
warnBg: string; warnText: string;
|
|
144
|
-
brandTint: string; signalTint: string;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const light: Palette = ${rnPalette(T.palettes.light)};
|
|
148
|
-
|
|
149
|
-
const dark: Palette = ${rnPalette(T.palettes.dark)};
|
|
150
|
-
|
|
151
|
-
export const palettes = { light, dark };
|
|
152
|
-
|
|
153
|
-
/** Back-compat default (light). */
|
|
154
|
-
export const colors = light;
|
|
155
|
-
|
|
156
|
-
export const status: Record<string, string> = ${rec(T.status)};
|
|
157
|
-
|
|
158
|
-
export const priority: Record<string, string> = ${rec(T.priority)};
|
|
159
|
-
|
|
160
|
-
export const space = { ${Object.entries(T.space).map(([k, v]) => `${k}: ${v}`).join(", ")} };
|
|
161
|
-
|
|
162
|
-
/** Subtle card elevation per scheme — RN has no box-shadow string, so this mirrors
|
|
163
|
-
* the web \`--shadow\` token as style props (iOS shadow*; Android elevation). */
|
|
164
|
-
export const shadow = {
|
|
165
|
-
light: ${rnShadow(T.palettes.light.shadowRn)},
|
|
166
|
-
dark: ${rnShadow(T.palettes.dark.shadowRn)},
|
|
167
|
-
} as const;
|
|
168
|
-
`;
|
|
169
|
-
|
|
170
|
-
/* ── write ────────────────────────────────────────────────────────────────── */
|
|
171
|
-
fs.writeFileSync(path.join(__dirname, "web", "theme.css"), themeCss);
|
|
172
|
-
fs.writeFileSync(path.join(__dirname, "mobile", "tokens.ts"), tokensTs);
|
|
173
|
-
console.log("field-ops-theme: generated web/theme.css + mobile/tokens.ts from tokens.json");
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* @aria-framework/theme generator. Reads tokens.json (single source of record) and emits
|
|
3
|
+
* - web/theme.css (Bootstrap 5.3 token layer + static mapping/edge/buttons/a11y)
|
|
4
|
+
* - mobile/tokens.ts (RN Palette + palettes/colors/status/priority/space/shadow)
|
|
5
|
+
* Run: `node build.js`. The two outputs are committed artifacts (consumers don't
|
|
6
|
+
* need to run this); regenerate whenever tokens.json changes, then bump the version. */
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const T = require("./tokens.json");
|
|
10
|
+
|
|
11
|
+
const GEN_CSS = "/* AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`. */";
|
|
12
|
+
const GEN_TS = "// AUTO-GENERATED from tokens.json by build.js — DO NOT EDIT. Change tokens.json, run `node build.js`.";
|
|
13
|
+
const FONT_SANS = `'Hanken Grotesk', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
|
|
14
|
+
const FONT_MONO = `'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
15
|
+
|
|
16
|
+
const hexToRgb = (hex) => {
|
|
17
|
+
const n = parseInt(hex.replace("#", ""), 16);
|
|
18
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(",");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Fail fast with a real message rather than a TypeError from inside a template.
|
|
22
|
+
for (const key of ["palettes", "sidebar", "status", "priority", "space"]) {
|
|
23
|
+
if (!T[key]) throw new Error(`tokens.json is missing the "${key}" section (required since theme 0.4.0)`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ── web/theme.css ────────────────────────────────────────────────────────── */
|
|
27
|
+
const cssTokens = (p) => ` --brand:${p.brand}; --brand-deep:${p.brandDeep}; --brand-bright:${p.brandBright}; --brand-rgb:${hexToRgb(p.brand)};
|
|
28
|
+
--signal:${p.signal};
|
|
29
|
+
--paper:${p.paper}; --surface:${p.surface}; --surface-2:${p.surface2}; --line:${p.line}; --line-strong:${p.lineStrong};
|
|
30
|
+
--text:${p.text}; --text-soft:${p.textSoft}; --text-faint:${p.textFaint};
|
|
31
|
+
--shadow:${p.shadowWeb};
|
|
32
|
+
--shadow-lg:${p.shadowLgWeb};`;
|
|
33
|
+
|
|
34
|
+
const themeCss = `${GEN_CSS}
|
|
35
|
+
/* @aria-framework/theme — Field Ops Console design system (web).
|
|
36
|
+
Drop-in token layer for any Bootstrap 5.3 app. Load order in <head>:
|
|
37
|
+
bootstrap.min.css → theme.css → app.css
|
|
38
|
+
Serve web/ same-origin (e.g. mount at /theme) so fonts satisfy CSP font-src 'self'. */
|
|
39
|
+
|
|
40
|
+
/* ── Design tokens — Ops Light in :root; Ops Dark keyed on [data-bs-theme] ───*/
|
|
41
|
+
:root{
|
|
42
|
+
--font-sans:${FONT_SANS};
|
|
43
|
+
--font-mono:${FONT_MONO};
|
|
44
|
+
${cssTokens(T.palettes.light)}
|
|
45
|
+
/* Sidebar rail (scheme-independent: dark in both modes) */
|
|
46
|
+
--side-bg1:${T.sidebar.bg1}; --side-bg2:${T.sidebar.bg2}; --side-tx:${T.sidebar.tx}; --side-muted:${T.sidebar.muted};
|
|
47
|
+
--side-active:${T.sidebar.active}; --side-line:${T.sidebar.line}; --side-card:${T.sidebar.card};
|
|
48
|
+
}
|
|
49
|
+
[data-bs-theme="dark"]{
|
|
50
|
+
${cssTokens(T.palettes.dark)}
|
|
51
|
+
}`;
|
|
52
|
+
|
|
53
|
+
/* ── mobile/tokens.ts ─────────────────────────────────────────────────────── */
|
|
54
|
+
const lc = (h) => h.toLowerCase();
|
|
55
|
+
const rnPalette = (p) => `{
|
|
56
|
+
bg: "${lc(p.paper)}",
|
|
57
|
+
surface: "${lc(p.surface)}",
|
|
58
|
+
surface2: "${lc(p.surface2)}",
|
|
59
|
+
border: "${lc(p.line)}",
|
|
60
|
+
lineStrong: "${lc(p.lineStrong)}",
|
|
61
|
+
text: "${lc(p.text)}",
|
|
62
|
+
muted: "${lc(p.textSoft)}",
|
|
63
|
+
textFaint: "${lc(p.textFaint)}",
|
|
64
|
+
primary: "${lc(p.brand)}",
|
|
65
|
+
primaryText: "${lc(p.primaryText)}",
|
|
66
|
+
brandDeep: "${lc(p.brandDeep)}",
|
|
67
|
+
brandBright: "${lc(p.brandBright)}",
|
|
68
|
+
danger: "${lc(p.danger)}",
|
|
69
|
+
success: "${lc(p.success)}",
|
|
70
|
+
signal: "${lc(p.signal)}",
|
|
71
|
+
warnBg: "${lc(p.warnBg)}",
|
|
72
|
+
warnText: "${lc(p.warnText)}",
|
|
73
|
+
brandTint: "${p.brandTint}",
|
|
74
|
+
signalTint: "${p.signalTint}",
|
|
75
|
+
}`;
|
|
76
|
+
const rnShadow = (s) => `{ shadowColor: "${lc(s.shadowColor)}", shadowOpacity: ${s.shadowOpacity}, shadowRadius: ${s.shadowRadius}, shadowOffset: { width: ${s.shadowOffset.width}, height: ${s.shadowOffset.height} }, elevation: ${s.elevation} }`;
|
|
77
|
+
const rec = (o) => "{\n" + Object.entries(o).map(([k, v]) => ` ${k}: "${v}",`).join("\n") + "\n}";
|
|
78
|
+
|
|
79
|
+
const tokensTs = `${GEN_TS}
|
|
80
|
+
/**
|
|
81
|
+
* Field Ops Console — shared visual tokens (React Native side).
|
|
82
|
+
* \`palettes.light|dark\` are consumed by ThemeContext; \`colors\` is the light
|
|
83
|
+
* default kept for back-compat with screens that import it directly.
|
|
84
|
+
*/
|
|
85
|
+
export type Palette = {
|
|
86
|
+
bg: string; surface: string; surface2: string; border: string; lineStrong: string;
|
|
87
|
+
text: string; muted: string; textFaint: string;
|
|
88
|
+
primary: string; primaryText: string; brandDeep: string; brandBright: string;
|
|
89
|
+
danger: string; success: string; signal: string;
|
|
90
|
+
warnBg: string; warnText: string;
|
|
91
|
+
brandTint: string; signalTint: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const light: Palette = ${rnPalette(T.palettes.light)};
|
|
95
|
+
|
|
96
|
+
const dark: Palette = ${rnPalette(T.palettes.dark)};
|
|
97
|
+
|
|
98
|
+
export const palettes = { light, dark };
|
|
99
|
+
|
|
100
|
+
/** Back-compat default (light). */
|
|
101
|
+
export const colors = light;
|
|
102
|
+
|
|
103
|
+
export const status: Record<string, string> = ${rec(T.status)};
|
|
104
|
+
|
|
105
|
+
export const priority: Record<string, string> = ${rec(T.priority)};
|
|
106
|
+
|
|
107
|
+
export const space = { ${Object.entries(T.space).map(([k, v]) => `${k}: ${v}`).join(", ")} };
|
|
108
|
+
|
|
109
|
+
/** Subtle card elevation per scheme — RN has no box-shadow string, so this mirrors
|
|
110
|
+
* the web \`--shadow\` token as style props (iOS shadow*; Android elevation). */
|
|
111
|
+
export const shadow = {
|
|
112
|
+
light: ${rnShadow(T.palettes.light.shadowRn)},
|
|
113
|
+
dark: ${rnShadow(T.palettes.dark.shadowRn)},
|
|
114
|
+
} as const;
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
/* ── assemble theme.css: generated tokens → src/base.css → src/components/* ──
|
|
118
|
+
base.css is the static layer (fonts, Bootstrap map, edge-clarity, buttons,
|
|
119
|
+
a11y). Each src/components/*.css is a self-contained component (markup
|
|
120
|
+
contract in the file header + README), appended in filename order so output
|
|
121
|
+
is deterministic. Everything references the generated tokens above. */
|
|
122
|
+
const baseCss = fs.readFileSync(path.join(__dirname, "src", "base.css"), "utf8").trimEnd();
|
|
123
|
+
const compDir = path.join(__dirname, "src", "components");
|
|
124
|
+
const components = fs.existsSync(compDir)
|
|
125
|
+
? fs.readdirSync(compDir).filter((f) => f.endsWith(".css")).sort()
|
|
126
|
+
.map((f) => fs.readFileSync(path.join(compDir, f), "utf8").trimEnd())
|
|
127
|
+
: [];
|
|
128
|
+
const fullCss = [themeCss.trimEnd(), baseCss, ...components].join("\n\n") + "\n";
|
|
129
|
+
|
|
130
|
+
/* ── write ────────────────────────────────────────────────────────────────── */
|
|
131
|
+
fs.writeFileSync(path.join(__dirname, "web", "theme.css"), fullCss);
|
|
132
|
+
fs.writeFileSync(path.join(__dirname, "mobile", "tokens.ts"), tokensTs);
|
|
133
|
+
console.log(`@aria-framework/theme: generated web/theme.css (+${components.length} component(s)) + mobile/tokens.ts from tokens.json`);
|
package/dist/ThemeContext.js
CHANGED
|
@@ -16,7 +16,7 @@ const react_native_1 = require("react-native");
|
|
|
16
16
|
const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
|
|
17
17
|
const tokens_1 = require("./tokens");
|
|
18
18
|
/** Default AsyncStorage key. Override per-app via <ThemeProvider storageKey="myapp.theme">
|
|
19
|
-
* so multiple
|
|
19
|
+
* so multiple aria-framework apps on a device don't share one preference. */
|
|
20
20
|
const DEFAULT_KEY = "field-ops.theme";
|
|
21
21
|
const Ctx = (0, react_1.createContext)(undefined);
|
|
22
22
|
function ThemeProvider({ children, storageKey = DEFAULT_KEY }) {
|
package/dist/index.d.ts
CHANGED
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.0",
|
|
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,109 @@
|
|
|
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
|
+
// currentScript is only set during initial execution — capture before deferring.
|
|
25
|
+
var script = document.currentScript;
|
|
26
|
+
var KEY = (script && script.dataset && script.dataset.storageKey) || 'aria-sidebar-collapsed';
|
|
27
|
+
|
|
28
|
+
function init() {
|
|
29
|
+
var root = document.documentElement;
|
|
30
|
+
|
|
31
|
+
// Backdrop for the mobile overlay (created once). Its computed display is
|
|
32
|
+
// our single source of truth for "mobile layout" — `block` only inside the
|
|
33
|
+
// mobile media query, `none` on desktop.
|
|
34
|
+
var backdrop = document.getElementById('sidebar-backdrop');
|
|
35
|
+
if (!backdrop) {
|
|
36
|
+
backdrop = document.createElement('div');
|
|
37
|
+
backdrop.id = 'sidebar-backdrop';
|
|
38
|
+
document.body.appendChild(backdrop);
|
|
39
|
+
}
|
|
40
|
+
function isMobile() { return getComputedStyle(backdrop).display !== 'none'; }
|
|
41
|
+
|
|
42
|
+
var content = document.getElementById('page-content-wrapper'); // inert while overlay is open
|
|
43
|
+
var lastFocus = null;
|
|
44
|
+
|
|
45
|
+
// Reflect state to assistive tech: the header chevron announces rail
|
|
46
|
+
// expanded/collapsed; the topbar hamburger announces overlay open/closed.
|
|
47
|
+
// Buttons are queried live so late-added toggles stay in sync.
|
|
48
|
+
function syncAria() {
|
|
49
|
+
var collapsed = root.classList.contains('sidebar-collapsed');
|
|
50
|
+
var open = root.classList.contains('sidebar-open');
|
|
51
|
+
document.querySelectorAll('[data-sidebar-toggle]').forEach(function (b) {
|
|
52
|
+
b.setAttribute('aria-expanded', String(b.classList.contains('s-collapse') ? !collapsed : open));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `opener` is the button that opened the overlay — restore focus to it on
|
|
57
|
+
// close (activeElement isn't reliable: some browsers don't focus a button
|
|
58
|
+
// on mouse click). Make the background inert so Tab can't escape the overlay.
|
|
59
|
+
function openOverlay(opener) {
|
|
60
|
+
lastFocus = opener || document.activeElement;
|
|
61
|
+
root.classList.add('sidebar-open');
|
|
62
|
+
if (content) content.inert = true;
|
|
63
|
+
var first = document.querySelector('.s-nav .s-link');
|
|
64
|
+
if (first) first.focus(); // move focus into the opened menu
|
|
65
|
+
syncAria();
|
|
66
|
+
}
|
|
67
|
+
function closeOverlay(restoreFocus) {
|
|
68
|
+
root.classList.remove('sidebar-open');
|
|
69
|
+
if (content) content.inert = false; // un-inert before restoring focus into it
|
|
70
|
+
if (restoreFocus && lastFocus && lastFocus.focus) lastFocus.focus();
|
|
71
|
+
lastFocus = null;
|
|
72
|
+
syncAria();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toggle(opener) {
|
|
76
|
+
if (isMobile()) {
|
|
77
|
+
if (root.classList.contains('sidebar-open')) closeOverlay(true); else openOverlay(opener);
|
|
78
|
+
} else {
|
|
79
|
+
var collapsed = root.classList.toggle('sidebar-collapsed');
|
|
80
|
+
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* storage off */ }
|
|
81
|
+
syncAria();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Delegated clicks: toggles work wherever/whenever they're added; tapping a
|
|
86
|
+
// nav link on mobile closes the overlay (it also navigates — no focus to
|
|
87
|
+
// restore). Backdrop tap and Escape close it too.
|
|
88
|
+
document.addEventListener('click', function (e) {
|
|
89
|
+
var t = e.target.closest && e.target.closest('[data-sidebar-toggle]');
|
|
90
|
+
if (t) { toggle(t); return; }
|
|
91
|
+
if (e.target === backdrop) { closeOverlay(true); return; }
|
|
92
|
+
if (isMobile() && e.target.closest && e.target.closest('.s-nav .s-link')) closeOverlay(false);
|
|
93
|
+
});
|
|
94
|
+
document.addEventListener('keydown', function (e) {
|
|
95
|
+
if (e.key === 'Escape' && root.classList.contains('sidebar-open')) closeOverlay(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Growing back to desktop clears any transient mobile-open state. Cheap
|
|
99
|
+
// guard first, so getComputedStyle only runs while an overlay is open.
|
|
100
|
+
window.addEventListener('resize', function () {
|
|
101
|
+
if (root.classList.contains('sidebar-open') && !isMobile()) closeOverlay(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
syncAria(); // initial state (the app's early boot script may pre-set sidebar-collapsed)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|
108
|
+
else init();
|
|
109
|
+
})();
|
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
|
+
}
|