@startup-api/cloudflare 0.3.0 → 0.3.2

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 CHANGED
@@ -12,36 +12,35 @@ This application uses the Cloudflare Developer Platform, including Workers and D
12
12
 
13
13
  ## Installation
14
14
 
15
- ### Option 1: Cloudflare Workers GitHub Integration (Recommended)
15
+ Start a new project with the **`npm create startup-api`** scaffolder. It generates a tiny Cloudflare Worker that pulls this framework in as the [`@startup-api/cloudflare`](https://www.npmjs.com/package/@startup-api/cloudflare) npm dependency — so you stay up to date with `npm update` instead of maintaining a fork of this repository.
16
16
 
17
- This is the easiest way to deploy and keep your worker up to date.
17
+ ```bash
18
+ npm create startup-api my-app -- --origin https://your-app-origin.com
19
+ cd my-app
20
+ npm run dev # local dev at http://localhost:8787
21
+ npm run deploy # deploy to Cloudflare
22
+ ```
23
+
24
+ Run `npm create startup-api` with no arguments to be prompted for the project name and origin URL interactively. Useful flags: `--no-install` (skip `npm install`) and `--yes` / `-y` (non-interactive — requires a `name` and `--origin`).
25
+
26
+ What you get:
18
27
 
19
- 1. **Fork this repository** to your account
20
- 2. Go to your [Cloudflare Dashboard's Workers & pages > Create Application](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
21
- 3. Click **Continue with GitHub**
22
- 4. Select your forked `startup-api-cloudflare` repository
23
- 5. Pick the name for your site's worker (e.g. you might have multiple)
24
- 6. Deploy the Worker
25
- 7. In the **Settings** tab of your Worker, go to **Variables** and add the required `ORIGIN_URL` (see [Configuration](#configuration-details) below)
28
+ - A minimal `src/index.ts` that re-exports the worker plus a `wrangler.jsonc` you control. The framework ships as the `@startup-api/cloudflare` dependency, so your project stays small.
29
+ - A `.dev.vars` file with a random `SESSION_SECRET` for local development. For production, set your own with `npx wrangler secret put SESSION_SECRET`.
30
+ - Framework updates are just `npm update @startup-api/cloudflare` — no fork to rebase.
26
31
 
27
- ### Option 2: Manual Installation (CLI)
32
+ Then set the required `ORIGIN_URL` and any OAuth credentials (see [Configuration](#configuration-details) below) and run `npm run deploy`. See [create-startup-api](https://github.com/StartupAPI/create-startup-api) for full details.
28
33
 
29
- Use this option if you want to deploy from your local machine.
34
+ ### Automated deployments
30
35
 
31
- 1. **Clone and Install**
32
- ```bash
33
- git clone https://github.com/StartupAPI/startup-api-cloudflare.git
34
- cd startup-api-cloudflare
35
- npm install
36
- ```
37
- 2. **Configure Environment Variables**
36
+ `npm run deploy` deploys from your machine. To deploy automatically instead, push your scaffolded project to a GitHub repository and use either:
38
37
 
39
- Update `wrangler.jsonc` or use dashboard **Settings** tab of your Worker, go to **Variables** and add the required `ORIGIN_URL` (see [Configuration](#configuration-details) below)
38
+ - **Cloudflare Workers GitHub app** connect the repo to Cloudflare's [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) Git integration and Cloudflare builds and deploys on every push, no CI config to maintain.
39
+ - **A GitHub Actions workflow** — run [`cloudflare/wrangler-action`](https://github.com/cloudflare/wrangler-action) on push to deploy with Wrangler. Add a `CLOUDFLARE_API_TOKEN` (and `CLOUDFLARE_ACCOUNT_ID`) repository secret so the action can authenticate.
40
40
 
41
- 3. **Deploy**
42
- ```bash
43
- npm run deploy
44
- ```
41
+ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on the Worker in the Cloudflare dashboard or with `npx wrangler secret put` rather than committing them.
42
+
43
+ > **Working on the framework itself?** See [CONTRIBUTING.md](./CONTRIBUTING.md) for cloning and running this repository locally.
45
44
 
46
45
  ## Configuration Details
47
46
 
@@ -58,18 +57,18 @@ Use this option if you want to deploy from your local machine.
58
57
  - **Using `wrangler.jsonc`:**
59
58
  Add the variables to the `"vars"` object in your configuration file. See [Cloudflare documentation](https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables) for more details.
60
59
 
61
- | Variable | Required | Default | Description |
62
- | :--------------------- | :------- | :-------- | :---------------------------------------------------------------------------- |
63
- | `ORIGIN_URL` | **Yes** | N/A | The base URL of your origin application (e.g., `https://your-app-origin.com`) |
64
- | `USERS_PATH` | No | `/users/` | The path used to serve internal assets like `power-strip.js` |
65
- | `AUTH_ORIGIN` | No | N/A | Optional base URL for OAuth redirects (overrides request origin) |
66
- | `GOOGLE_CLIENT_ID` | No | N/A | Google OAuth2 Client ID |
67
- | `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
68
- | `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
69
- | `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
70
- | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
71
- | `PATREON_CLIENT_SECRET`| No | N/A | Patreon OAuth2 Client Secret |
72
- | `PATREON_WEBHOOK_SECRET`| No | N/A | Secret for verifying Patreon webhook signatures |
60
+ | Variable | Required | Default | Description |
61
+ | :----------------------- | :------- | :-------- | :---------------------------------------------------------------------------- |
62
+ | `ORIGIN_URL` | **Yes** | N/A | The base URL of your origin application (e.g., `https://your-app-origin.com`) |
63
+ | `USERS_PATH` | No | `/users/` | The path used to serve internal assets like `power-strip.js` |
64
+ | `AUTH_ORIGIN` | No | N/A | Optional base URL for OAuth redirects (overrides request origin) |
65
+ | `GOOGLE_CLIENT_ID` | No | N/A | Google OAuth2 Client ID |
66
+ | `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
67
+ | `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
68
+ | `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
69
+ | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
70
+ | `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
71
+ | `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
73
72
 
74
73
  > Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values `ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, and `ENVIRONMENT`. **All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to the `createStartupAPI` factory** (see [Access policy & provider entitlements](#access-policy--provider-entitlements)).
75
74
 
@@ -144,7 +143,7 @@ By default the worker injects its own `<power-strip>` pinned to the top-right co
144
143
  ```
145
144
 
146
145
  - **`providers` is optional.** If you omit it, the worker fills in the active providers for you (e.g. `providers="google,twitch,patreon"`). Set it yourself to override which login buttons appear.
147
- - **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is *not* truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
146
+ - **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is _not_ truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
148
147
  - **Script-only opt-out.** Use `<power-strip hidden>` to load `power-strip.js` (and its JS API) without rendering a visible strip.
149
148
 
150
149
  ## Access policy & provider entitlements
@@ -243,7 +242,12 @@ const api = createStartupAPI({
243
242
  freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
244
243
  },
245
244
  },
246
- accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
245
+ accessPolicy: {
246
+ rules: [
247
+ /* ... */
248
+ ],
249
+ default: { mode: 'public' },
250
+ },
247
251
  });
248
252
 
249
253
  export default api.default; // includes scheduled() because cron is enabled
@@ -254,7 +258,7 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
254
258
 
255
259
  ## Contributing
256
260
 
257
- Contributions are welcome! Please feel free to submit a Pull Request.
261
+ Contributions are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to clone, run, test, and submit changes to the framework.
258
262
 
259
263
  ## License
260
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -12,10 +12,7 @@
12
12
  data-ssr-members="{{ssr:account_members_json}}"
13
13
  data-ssr-plans="{{ssr:plans_json}}"
14
14
  >
15
- <power-strip
16
- providers="{{ssr:providers}}"
17
- style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
18
- >
15
+ <power-strip providers="{{ssr:providers}}" style="position: absolute; top: 0; right: 0; z-index: 9999; border-radius: 0 0 0 0.3rem">
19
16
  <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
20
17
  </power-strip>
21
18
  <script src="/users/power-strip.js" async></script>
@@ -61,7 +58,7 @@
61
58
  <div
62
59
  id="account-avatar-placeholder"
63
60
  class="account-avatar-large"
64
- style="background: #f1f3f4; {{ssr:account_placeholder_display}} align-items: center; justify-content: center; color: #5f6368"
61
+ style="background: var(--surface-alt); {{ssr:account_placeholder_display}} align-items: center; justify-content: center; color: var(--muted-badge-text)"
65
62
  >
66
63
  <svg viewBox="0 0 24 24" style="width: 48px; height: 48px; fill: currentColor">
67
64
  <path
@@ -97,8 +94,8 @@
97
94
  </button>
98
95
  </div>
99
96
  <div>
100
- <h2 id="display-account-name" style="margin: 0; color: #333">{{ssr:account_name}}</h2>
101
- <p id="display-account-plan" style="margin: 0.25rem 0 0 0; color: #666">{{ssr:account_plan_name}}</p>
97
+ <h2 id="display-account-name" style="margin: 0; color: var(--text)">{{ssr:account_name}}</h2>
98
+ <p id="display-account-plan" style="margin: 0.25rem 0 0 0; color: var(--text-faint)">{{ssr:account_plan_name}}</p>
102
99
  </div>
103
100
  </div>
104
101
 
@@ -5,6 +5,56 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Admin Dashboard</title>
7
7
  <style>
8
+ /* Color tokens, flipped by the user's OS color-scheme preference (with a
9
+ [data-theme] escape hatch) — same approach as the shared user pages
10
+ (/users/style.css) and the landing page. */
11
+ :root {
12
+ --bg: #f9f9f9;
13
+ --surface: #fff;
14
+ --surface-muted: #eee;
15
+ --hover-bg: #f0f0f0;
16
+ --text: #333;
17
+ --border: #ddd;
18
+ --border-light: #eee;
19
+ --accent: #ffcc00;
20
+ --accent-hover: #e6b800;
21
+ --on-accent: #202124;
22
+ --secondary-btn-bg: #eee;
23
+ --secondary-btn-hover-bg: #e0e0e0;
24
+ }
25
+
26
+ @media (prefers-color-scheme: dark) {
27
+ :root:not([data-theme='light']) {
28
+ --bg: #1a1a1a;
29
+ --surface: #2d2d2d;
30
+ --surface-muted: #333;
31
+ --hover-bg: #3c4043;
32
+ --text: #e0e0e0;
33
+ --border: #5f6368;
34
+ --border-light: #444;
35
+ --accent: #ffcc00;
36
+ --accent-hover: #e6b800;
37
+ --on-accent: #202124;
38
+ --secondary-btn-bg: #3c4043;
39
+ --secondary-btn-hover-bg: #4a4f54;
40
+ }
41
+ }
42
+
43
+ [data-theme='dark'] {
44
+ --bg: #1a1a1a;
45
+ --surface: #2d2d2d;
46
+ --surface-muted: #333;
47
+ --hover-bg: #3c4043;
48
+ --text: #e0e0e0;
49
+ --border: #5f6368;
50
+ --border-light: #444;
51
+ --accent: #ffcc00;
52
+ --accent-hover: #e6b800;
53
+ --on-accent: #202124;
54
+ --secondary-btn-bg: #3c4043;
55
+ --secondary-btn-hover-bg: #4a4f54;
56
+ }
57
+
8
58
  body {
9
59
  font-family:
10
60
  system-ui,
@@ -13,14 +63,15 @@
13
63
  padding: 2rem;
14
64
  max-width: 1200px;
15
65
  margin: 0 auto;
16
- background: #f9f9f9;
66
+ background: var(--bg);
67
+ color: var(--text);
17
68
  }
18
69
  h1,
19
70
  h2 {
20
- color: #333;
71
+ color: var(--text);
21
72
  }
22
73
  section {
23
- background: white;
74
+ background: var(--surface);
24
75
  padding: 1.5rem;
25
76
  border-radius: 8px;
26
77
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@@ -33,20 +84,22 @@
33
84
  }
34
85
  input[type='text'] {
35
86
  padding: 0.5rem;
36
- border: 1px solid #ddd;
87
+ border: 1px solid var(--border);
37
88
  border-radius: 4px;
38
89
  flex-grow: 1;
90
+ background: var(--surface);
91
+ color: var(--text);
39
92
  }
40
93
  button {
41
94
  padding: 0.5rem 1rem;
42
- background: #1a73e8;
43
- color: white;
95
+ background: var(--accent);
96
+ color: var(--on-accent);
44
97
  border: none;
45
98
  border-radius: 4px;
46
99
  cursor: pointer;
47
100
  }
48
101
  button:hover {
49
- background: #1557b0;
102
+ background: var(--accent-hover);
50
103
  }
51
104
  table {
52
105
  width: 100%;
@@ -56,21 +109,21 @@
56
109
  td {
57
110
  text-align: left;
58
111
  padding: 0.75rem;
59
- border-bottom: 1px solid #eee;
112
+ border-bottom: 1px solid var(--border-light);
60
113
  }
61
114
  th {
62
- background: #f0f0f0;
115
+ background: var(--hover-bg);
63
116
  }
64
117
  .actions {
65
118
  display: flex;
66
119
  gap: 0.5rem;
67
120
  }
68
121
  .secondary-btn {
69
- background: #eee;
70
- color: #333;
122
+ background: var(--secondary-btn-bg);
123
+ color: var(--text);
71
124
  }
72
125
  .secondary-btn:hover {
73
- background: #e0e0e0;
126
+ background: var(--secondary-btn-hover-bg);
74
127
  }
75
128
  #toast {
76
129
  position: fixed;
@@ -89,6 +142,8 @@
89
142
  border-radius: 8px;
90
143
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
91
144
  width: 400px;
145
+ background: var(--surface);
146
+ color: var(--text);
92
147
  }
93
148
  dialog::backdrop {
94
149
  background: rgba(0, 0, 0, 0.5);
@@ -108,9 +163,11 @@
108
163
  .form-group select {
109
164
  width: 100%;
110
165
  padding: 0.5rem;
111
- border: 1px solid #ddd;
166
+ border: 1px solid var(--border);
112
167
  border-radius: 4px;
113
168
  box-sizing: border-box;
169
+ background: var(--surface);
170
+ color: var(--text);
114
171
  }
115
172
  .modal-actions {
116
173
  display: flex;
@@ -121,10 +178,7 @@
121
178
  </style>
122
179
  </head>
123
180
  <body data-ssr-plans="{{ssr:plans_json}}">
124
- <power-strip
125
- providers="{{ssr:providers}}"
126
- style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
127
- >
181
+ <power-strip providers="{{ssr:providers}}" style="position: absolute; top: 0; right: 0; z-index: 9999; border-radius: 0 0 0 0.3rem">
128
182
  <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
129
183
  </power-strip>
130
184
  <script src="/users/power-strip.js" async></script>
@@ -417,7 +471,7 @@
417
471
  <button class="secondary-btn" onclick="openEditUser('${u.id}')">Edit</button>
418
472
  <button class="secondary-btn" onclick="openUserMemberships('${u.id}')">Memberships</button>
419
473
  <button onclick="impersonate('${u.id}')">Impersonate</button>
420
- <button style="background: #d93025;" onclick="deleteUser('${u.id}')">Delete</button>
474
+ <button style="background: #d93025; color: #fff;" onclick="deleteUser('${u.id}')">Delete</button>
421
475
  </td>
422
476
  </tr>
423
477
  `,
@@ -443,7 +497,7 @@
443
497
  <td class="actions">
444
498
  <button class="secondary-btn" onclick="openEditAccount('${a.id}')">Edit</button>
445
499
  <button class="secondary-btn" onclick="openMembers('${a.id}')">Members</button>
446
- <button style="background: #d93025;" onclick="deleteAccount('${a.id}')">Delete</button>
500
+ <button style="background: #d93025; color: #fff;" onclick="deleteAccount('${a.id}')">Delete</button>
447
501
  </td>
448
502
  </tr>
449
503
  `;
@@ -5,6 +5,10 @@ class PowerStrip extends HTMLElement {
5
5
  this.basePath = this.detectBasePath();
6
6
  this.user = null;
7
7
  this.accounts = [];
8
+ // Theme watchers, wired up once on connect and torn down on disconnect.
9
+ this._mediaQuery = null;
10
+ this._onPreferenceChange = null;
11
+ this._pageObserver = null;
8
12
  }
9
13
 
10
14
  detectBasePath() {
@@ -27,17 +31,130 @@ class PowerStrip extends HTMLElement {
27
31
  }
28
32
 
29
33
  async connectedCallback() {
34
+ // Resolve the theme before the first paint so the strip never flashes the
35
+ // wrong colors, then keep it in sync with the page from here on.
36
+ this.applyTheme();
37
+ this.watchThemeChanges();
30
38
  await this.fetchUser();
31
39
  this.render();
32
40
  this.addEventListeners();
33
41
  }
34
42
 
43
+ disconnectedCallback() {
44
+ if (this._mediaQuery && this._onPreferenceChange) {
45
+ this._mediaQuery.removeEventListener('change', this._onPreferenceChange);
46
+ }
47
+ if (this._pageObserver) {
48
+ this._pageObserver.disconnect();
49
+ }
50
+ }
51
+
35
52
  async refresh() {
36
53
  await this.fetchUser();
37
54
  this.render();
38
55
  this.addEventListeners();
39
56
  }
40
57
 
58
+ /**
59
+ * Decide whether the strip should render light or dark by measuring the
60
+ * actual background the strip sits on. This makes the strip match the page
61
+ * regardless of *how* the page chose its theme — a hardcoded dark page, a
62
+ * hardcoded light page, or a page that respects the user's OS preference all
63
+ * resolve to a concrete background color we can read here. When nothing
64
+ * conclusive is found (e.g. a transparent body over an image) we fall back to
65
+ * the user's OS-level color-scheme preference.
66
+ */
67
+ detectPageTheme() {
68
+ const bg = this.getEffectiveBackgroundColor();
69
+ if (bg) {
70
+ return this.isDarkColor(bg) ? 'dark' : 'light';
71
+ }
72
+ return this.prefersDark() ? 'dark' : 'light';
73
+ }
74
+
75
+ prefersDark() {
76
+ return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
77
+ }
78
+
79
+ /**
80
+ * Walk up from the strip's placement looking for the first ancestor that
81
+ * paints an opaque (or partly opaque) background, mirroring how the strip is
82
+ * actually composited over the page. Falls back to the document element.
83
+ */
84
+ getEffectiveBackgroundColor() {
85
+ let el = this.parentElement;
86
+ while (el) {
87
+ const color = getComputedStyle(el).backgroundColor;
88
+ if (color && !this.isTransparentColor(color)) {
89
+ return color;
90
+ }
91
+ el = el.parentElement;
92
+ }
93
+ const rootColor = getComputedStyle(document.documentElement).backgroundColor;
94
+ if (rootColor && !this.isTransparentColor(rootColor)) {
95
+ return rootColor;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ parseColor(color) {
101
+ const parts = (color.match(/[\d.]+/g) || []).map(Number);
102
+ if (parts.length < 3) {
103
+ return null;
104
+ }
105
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length >= 4 ? parts[3] : 1 };
106
+ }
107
+
108
+ isTransparentColor(color) {
109
+ if (!color || color === 'transparent') {
110
+ return true;
111
+ }
112
+ const c = this.parseColor(color);
113
+ return !c || c.a === 0;
114
+ }
115
+
116
+ isDarkColor(color) {
117
+ const c = this.parseColor(color);
118
+ if (!c) {
119
+ return false;
120
+ }
121
+ // Perceived luminance (ITU-R BT.601). Below the midpoint reads as "dark".
122
+ const luminance = (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) / 255;
123
+ return luminance < 0.5;
124
+ }
125
+
126
+ /**
127
+ * Tag the host with the resolved theme. CSS keys all of its colors off this
128
+ * attribute, so updating it is enough to re-theme the whole shadow tree
129
+ * (panel, dialogs and all) without re-rendering or losing dialog state.
130
+ */
131
+ applyTheme() {
132
+ const theme = this.detectPageTheme();
133
+ if (this.getAttribute('data-resolved-theme') !== theme) {
134
+ this.setAttribute('data-resolved-theme', theme);
135
+ }
136
+ }
137
+
138
+ watchThemeChanges() {
139
+ // React to the user flipping their OS-level color-scheme preference.
140
+ if (typeof window.matchMedia === 'function') {
141
+ this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
142
+ this._onPreferenceChange = () => this.applyTheme();
143
+ this._mediaQuery.addEventListener('change', this._onPreferenceChange);
144
+ }
145
+
146
+ // React to the page re-theming itself at runtime — e.g. a theme toggle
147
+ // flipping data-theme/class/style on <html> or <body>.
148
+ if (typeof MutationObserver === 'function') {
149
+ this._pageObserver = new MutationObserver(() => this.applyTheme());
150
+ const observeOptions = { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] };
151
+ this._pageObserver.observe(document.documentElement, observeOptions);
152
+ if (document.body) {
153
+ this._pageObserver.observe(document.body, observeOptions);
154
+ }
155
+ }
156
+ }
157
+
41
158
  async fetchUser() {
42
159
  try {
43
160
  const res = await fetch(`${this.basePath}/api/me`);
@@ -214,8 +331,8 @@ class PowerStrip extends HTMLElement {
214
331
 
215
332
  const avatarContent = this.user.profile.picture
216
333
  ? `<img src="${this.user.profile.picture}" alt="${this.user.profile.name}" title="${this.user.profile.name}" class="avatar" width="16" height="16" />`
217
- : `<div class="avatar placeholder" style="background: #eee; display: flex; align-items: center; justify-content: center;">
218
- <svg viewBox="0 0 24 24" style="width: 12px; height: 12px; fill: #999;">
334
+ : `<div class="avatar placeholder">
335
+ <svg viewBox="0 0 24 24" style="width: 12px; height: 12px;">
219
336
  <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
220
337
  </svg>
221
338
  </div>`;
@@ -251,6 +368,108 @@ class PowerStrip extends HTMLElement {
251
368
  :host {
252
369
  display: block;
253
370
  font-family: system-ui, -apple-system, sans-serif;
371
+
372
+ /* Light theme (the default). Every color in the strip is keyed off
373
+ these custom properties so the whole shadow tree can be re-themed
374
+ by flipping a single set of variables. The dark overrides live in
375
+ the rules below — one driven by the user's OS preference (used as a
376
+ flash-free fallback) and one driven by the [data-resolved-theme]
377
+ attribute the component measures and sets at runtime. */
378
+ --ps-panel-bg: rgba(255, 255, 255, 0.85);
379
+ --ps-panel-border: rgba(0, 0, 0, 0.15);
380
+ --ps-panel-shadow: 0 0.0625rem 0.25rem rgba(0, 0, 0, 0.25);
381
+ --ps-text: #333;
382
+ --ps-text-muted: #444;
383
+ --ps-accent: #1a73e8;
384
+ --ps-danger: #d93025;
385
+ --ps-warning: #c77700;
386
+ --ps-hover-bg: rgba(0, 0, 0, 0.06);
387
+ --ps-avatar-bg: #eee;
388
+ --ps-avatar-fg: #999;
389
+
390
+ --ps-dialog-bg: #fff;
391
+ --ps-dialog-text: #333;
392
+ --ps-dialog-shadow: 0 0.625rem 1.5625rem rgba(0, 0, 0, 0.25);
393
+ --ps-dialog-muted: #999;
394
+ --ps-dialog-hover-bg: #f0f0f0;
395
+ --ps-surface-bg: #fff;
396
+ --ps-surface-border: #ddd;
397
+ --ps-surface-soft-border: #eee;
398
+ --ps-surface-hover-bg: #f5f5f5;
399
+ --ps-neutral-btn-bg: #fff;
400
+ --ps-neutral-btn-text: #3c4043;
401
+ --ps-neutral-btn-border: #dadce0;
402
+ --ps-neutral-btn-hover-bg: #f8f9fa;
403
+ --ps-active-bg: #e8f0fe;
404
+
405
+ color-scheme: light;
406
+ }
407
+
408
+ /* Shared dark palette. Applied either when the page has been measured
409
+ as dark, or — before/without measurement — when the user's OS asks
410
+ for dark and the page hasn't been explicitly resolved to light. */
411
+ :host([data-resolved-theme='dark']) {
412
+ --ps-panel-bg: rgba(32, 33, 36, 0.92);
413
+ --ps-panel-border: rgba(255, 255, 255, 0.22);
414
+ --ps-panel-shadow: 0 0.0625rem 0.3125rem rgba(0, 0, 0, 0.65);
415
+ --ps-text: #e8eaed;
416
+ --ps-text-muted: #dadce0;
417
+ --ps-accent: #8ab4f8;
418
+ --ps-danger: #f28b82;
419
+ --ps-warning: #fdd663;
420
+ --ps-hover-bg: rgba(255, 255, 255, 0.12);
421
+ --ps-avatar-bg: #5f6368;
422
+ --ps-avatar-fg: #dadce0;
423
+
424
+ --ps-dialog-bg: #2a2b2e;
425
+ --ps-dialog-text: #e8eaed;
426
+ --ps-dialog-shadow: 0 0.625rem 1.5625rem rgba(0, 0, 0, 0.7);
427
+ --ps-dialog-muted: #9aa0a6;
428
+ --ps-dialog-hover-bg: #3c4043;
429
+ --ps-surface-bg: #303134;
430
+ --ps-surface-border: #5f6368;
431
+ --ps-surface-soft-border: #3c4043;
432
+ --ps-surface-hover-bg: #3c4043;
433
+ --ps-neutral-btn-bg: #303134;
434
+ --ps-neutral-btn-text: #e8eaed;
435
+ --ps-neutral-btn-border: #5f6368;
436
+ --ps-neutral-btn-hover-bg: #3c4043;
437
+ --ps-active-bg: #283142;
438
+
439
+ color-scheme: dark;
440
+ }
441
+
442
+ @media (prefers-color-scheme: dark) {
443
+ :host(:not([data-resolved-theme='light'])) {
444
+ --ps-panel-bg: rgba(32, 33, 36, 0.92);
445
+ --ps-panel-border: rgba(255, 255, 255, 0.22);
446
+ --ps-panel-shadow: 0 0.0625rem 0.3125rem rgba(0, 0, 0, 0.65);
447
+ --ps-text: #e8eaed;
448
+ --ps-text-muted: #dadce0;
449
+ --ps-accent: #8ab4f8;
450
+ --ps-danger: #f28b82;
451
+ --ps-warning: #fdd663;
452
+ --ps-hover-bg: rgba(255, 255, 255, 0.12);
453
+ --ps-avatar-bg: #5f6368;
454
+ --ps-avatar-fg: #dadce0;
455
+
456
+ --ps-dialog-bg: #2a2b2e;
457
+ --ps-dialog-text: #e8eaed;
458
+ --ps-dialog-shadow: 0 0.625rem 1.5625rem rgba(0, 0, 0, 0.7);
459
+ --ps-dialog-muted: #9aa0a6;
460
+ --ps-dialog-hover-bg: #3c4043;
461
+ --ps-surface-bg: #303134;
462
+ --ps-surface-border: #5f6368;
463
+ --ps-surface-soft-border: #3c4043;
464
+ --ps-surface-hover-bg: #3c4043;
465
+ --ps-neutral-btn-bg: #303134;
466
+ --ps-neutral-btn-text: #e8eaed;
467
+ --ps-neutral-btn-border: #5f6368;
468
+ --ps-neutral-btn-hover-bg: #3c4043;
469
+ --ps-active-bg: #283142;
470
+
471
+ color-scheme: dark;
472
+ }
254
473
  }
255
474
 
256
475
  /* Honor the native [hidden] attribute so authors can load the script
@@ -272,10 +491,16 @@ class PowerStrip extends HTMLElement {
272
491
  height: 1.3rem;
273
492
  padding: 0.0625rem;
274
493
  animation: fadeIn 0.4s ease-out;
275
- background-color: rgba(255, 255, 255, 0.7);
494
+ background-color: var(--ps-panel-bg);
495
+ /* A contrasting border keeps the chip distinguishable even when its
496
+ panel color happens to be close to the page background. */
497
+ border: 0.0625rem solid var(--ps-panel-border);
498
+ border-top: none;
499
+ border-right: none;
276
500
  border-radius: 0 0 0 0.3rem;
277
- box-shadow: 0 0.0625rem 0.1875rem rgba(0,0,0,0.1);
501
+ box-shadow: var(--ps-panel-shadow);
278
502
  font-size: 1rem;
503
+ backdrop-filter: blur(0.25rem);
279
504
  }
280
505
 
281
506
  .trigger {
@@ -285,7 +510,7 @@ class PowerStrip extends HTMLElement {
285
510
  border-radius: 0.25rem;
286
511
  font-size: 0.8rem;
287
512
  font-weight: 500;
288
- color: #444;
513
+ color: var(--ps-text-muted);
289
514
  text-decoration: none;
290
515
  border: none;
291
516
  background: transparent;
@@ -293,19 +518,19 @@ class PowerStrip extends HTMLElement {
293
518
  }
294
519
 
295
520
  .trigger:hover {
296
- background-color: rgba(0, 0, 0, 0.05);
521
+ background-color: var(--ps-hover-bg);
297
522
  text-decoration: underline;
298
- color: #1a73e8;
523
+ color: var(--ps-accent);
299
524
  }
300
-
525
+
301
526
  .switch-btn {
302
- color: #1a73e8;
527
+ color: var(--ps-accent);
303
528
  }
304
529
 
305
530
  svg.bolt, ::slotted(svg) {
306
531
  width: 1rem !important;
307
532
  height: 1rem !important;
308
- fill: #ffcc00 !important;
533
+ fill: #ffcc00 !important;
309
534
  filter: drop-shadow(0.0625rem 0.0625rem 0.0625rem rgba(0, 0, 0, 0.5));
310
535
  flex-shrink: 0;
311
536
  }
@@ -330,6 +555,17 @@ class PowerStrip extends HTMLElement {
330
555
  object-fit: cover;
331
556
  }
332
557
 
558
+ .avatar.placeholder {
559
+ background: var(--ps-avatar-bg);
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ }
564
+
565
+ .avatar.placeholder svg {
566
+ fill: var(--ps-avatar-fg);
567
+ }
568
+
333
569
  .provider-badge {
334
570
  position: absolute;
335
571
  bottom: -0.0625rem;
@@ -340,7 +576,7 @@ class PowerStrip extends HTMLElement {
340
576
  align-items: center;
341
577
  justify-content: center;
342
578
  }
343
-
579
+
344
580
  .provider-badge svg {
345
581
  width: 0.5rem;
346
582
  height: 0.5rem;
@@ -365,7 +601,7 @@ class PowerStrip extends HTMLElement {
365
601
 
366
602
  .user-name {
367
603
  font-size: 0.8rem;
368
- color: #333;
604
+ color: var(--ps-text);
369
605
  max-width: 10rem;
370
606
  white-space: nowrap;
371
607
  overflow: hidden;
@@ -377,12 +613,12 @@ class PowerStrip extends HTMLElement {
377
613
 
378
614
  .user-name:hover {
379
615
  text-decoration: underline;
380
- color: #1a73e8;
616
+ color: var(--ps-accent);
381
617
  }
382
-
618
+
383
619
  .account-label {
384
620
  font-size: 0.8rem;
385
- color: #1a73e8;
621
+ color: var(--ps-accent);
386
622
  max-width: 10rem;
387
623
  white-space: nowrap;
388
624
  overflow: hidden;
@@ -400,11 +636,11 @@ class PowerStrip extends HTMLElement {
400
636
  }
401
637
 
402
638
  .admin-btn {
403
- color: #d93025 !important;
639
+ color: var(--ps-danger) !important;
404
640
  }
405
641
 
406
642
  .stop-impersonation-btn {
407
- color: #fbbc05 !important;
643
+ color: var(--ps-warning) !important;
408
644
  font-weight: bold;
409
645
  }
410
646
 
@@ -419,9 +655,9 @@ class PowerStrip extends HTMLElement {
419
655
  border: none;
420
656
  border-radius: 0.75rem;
421
657
  padding: 0;
422
- box-shadow: 0 0.625rem 1.5625rem rgba(0,0,0,0.2);
423
- background: white;
424
- color: #333;
658
+ box-shadow: var(--ps-dialog-shadow);
659
+ background: var(--ps-dialog-bg);
660
+ color: var(--ps-dialog-text);
425
661
  max-width: 20rem;
426
662
  width: 90%;
427
663
  overflow: hidden;
@@ -442,7 +678,7 @@ class PowerStrip extends HTMLElement {
442
678
  align-items: center;
443
679
  margin-bottom: 1.25rem;
444
680
  }
445
-
681
+
446
682
  .dialog-title {
447
683
  font-weight: 700;
448
684
  font-size: 1.25rem;
@@ -454,7 +690,7 @@ class PowerStrip extends HTMLElement {
454
690
  border: none;
455
691
  cursor: pointer;
456
692
  font-size: 1.5rem;
457
- color: #999;
693
+ color: var(--ps-dialog-muted);
458
694
  padding: 0;
459
695
  line-height: 1;
460
696
  display: flex;
@@ -467,8 +703,8 @@ class PowerStrip extends HTMLElement {
467
703
  }
468
704
 
469
705
  .close-btn:hover {
470
- background-color: #f0f0f0;
471
- color: #333;
706
+ background-color: var(--ps-dialog-hover-bg);
707
+ color: var(--ps-dialog-text);
472
708
  }
473
709
 
474
710
  .auth-buttons {
@@ -479,7 +715,7 @@ class PowerStrip extends HTMLElement {
479
715
 
480
716
  .auth-btn {
481
717
  padding: 0.75rem 1rem;
482
- border: 1px solid #ddd;
718
+ border: 1px solid var(--ps-surface-border);
483
719
  border-radius: 0.375rem;
484
720
  cursor: pointer;
485
721
  display: flex;
@@ -491,7 +727,7 @@ class PowerStrip extends HTMLElement {
491
727
  transition: all 0.2s ease;
492
728
  text-decoration: none;
493
729
  color: inherit;
494
- background-color: white;
730
+ background-color: var(--ps-neutral-btn-bg);
495
731
  }
496
732
 
497
733
  .auth-btn:hover {
@@ -509,12 +745,12 @@ class PowerStrip extends HTMLElement {
509
745
  }
510
746
 
511
747
  .auth-btn.google {
512
- color: #3c4043;
513
- border-color: #dadce0;
748
+ color: var(--ps-neutral-btn-text);
749
+ background-color: var(--ps-neutral-btn-bg);
750
+ border-color: var(--ps-neutral-btn-border);
514
751
  }
515
752
  .auth-btn.google:hover {
516
- background-color: #f8f9fa;
517
- border-color: #d2e3fc;
753
+ background-color: var(--ps-neutral-btn-hover-bg);
518
754
  }
519
755
 
520
756
  .auth-btn.twitch {
@@ -543,12 +779,13 @@ class PowerStrip extends HTMLElement {
543
779
  flex-direction: column;
544
780
  gap: 0.5rem;
545
781
  }
546
-
782
+
547
783
  .account-item {
548
784
  padding: 0.75rem;
549
- border: 1px solid #eee;
785
+ border: 1px solid var(--ps-surface-soft-border);
550
786
  border-radius: 0.375rem;
551
- background: white;
787
+ background: var(--ps-surface-bg);
788
+ color: var(--ps-dialog-text);
552
789
  text-align: left;
553
790
  cursor: pointer;
554
791
  display: flex;
@@ -558,25 +795,25 @@ class PowerStrip extends HTMLElement {
558
795
  font-size: 1rem;
559
796
  gap: 1rem;
560
797
  }
561
-
798
+
562
799
  .account-item:hover {
563
- background-color: #f5f5f5;
800
+ background-color: var(--ps-surface-hover-bg);
564
801
  }
565
-
802
+
566
803
  .account-item.active {
567
- border-color: #1a73e8;
568
- background-color: #e8f0fe;
804
+ border-color: var(--ps-accent);
805
+ background-color: var(--ps-active-bg);
569
806
  }
570
-
807
+
571
808
  .current-badge {
572
809
  font-size: 0.75rem;
573
- background: #1a73e8;
810
+ background: var(--ps-accent);
574
811
  color: white;
575
812
  padding: 0.125rem 0.375rem;
576
813
  border-radius: 0.75rem;
577
814
  }
578
815
  </style>
579
-
816
+
580
817
  <div class="container">
581
818
  ${content}
582
819
  <slot></slot>
@@ -593,7 +830,7 @@ class PowerStrip extends HTMLElement {
593
830
  </div>
594
831
  </div>
595
832
  </dialog>
596
-
833
+
597
834
  ${accountSwitcher}
598
835
  `;
599
836
  }
@@ -7,10 +7,7 @@
7
7
  <link rel="stylesheet" href="/users/style.css" />
8
8
  </head>
9
9
  <body data-ssr-profile="{{ssr:profile_json}}" data-ssr-credentials="{{ssr:credentials_json}}">
10
- <power-strip
11
- providers="{{ssr:providers}}"
12
- style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
13
- >
10
+ <power-strip providers="{{ssr:providers}}" style="position: absolute; top: 0; right: 0; z-index: 9999; border-radius: 0 0 0 0.3rem">
14
11
  <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
15
12
  </power-strip>
16
13
  <script src="/users/power-strip.js" async></script>
@@ -56,7 +53,7 @@
56
53
  <div
57
54
  id="profile-avatar-placeholder"
58
55
  class="avatar-large"
59
- style="background: #f1f3f4; {{ssr:profile_placeholder_display}} align-items: center; justify-content: center; color: #5f6368"
56
+ style="background: var(--surface-alt); {{ssr:profile_placeholder_display}} align-items: center; justify-content: center; color: var(--muted-badge-text)"
60
57
  >
61
58
  <svg
62
59
  viewBox="0 0 24 24"
@@ -99,7 +96,7 @@
99
96
  </button>
100
97
  </div>
101
98
  <div>
102
- <p id="display-email" style="margin: 0.25rem 0 0 0; color: #666">{{ssr:profile_email}}</p>
99
+ <p id="display-email" style="margin: 0.25rem 0 0 0; color: var(--text-faint)">{{ssr:profile_email}}</p>
103
100
  </div>
104
101
  </div>
105
102
 
@@ -125,7 +122,7 @@
125
122
 
126
123
  <section>
127
124
  <h2>Login Credentials</h2>
128
- <p style="color: #666; font-size: 0.9rem; margin-bottom: 1.5rem">Manage the login methods linked to your account.</p>
125
+ <p style="color: var(--text-faint); font-size: 0.9rem; margin-bottom: 1.5rem">Manage the login methods linked to your account.</p>
129
126
 
130
127
  <div id="credentials-list" style="margin-bottom: 2rem">{{ssr:credentials_list_html}}</div>
131
128
 
@@ -383,7 +380,7 @@
383
380
  ${c.provider.charAt(0).toUpperCase() + c.provider.slice(1)}
384
381
  ${isCurrent ? '<span class="current-badge">logged in</span>' : ''}
385
382
  </div>
386
- <div style="font-size: 0.8rem; color: #666;">${c.email || c.subject_id}</div>
383
+ <div style="font-size: 0.8rem; color: var(--text-faint);">${c.email || c.subject_id}</div>
387
384
  </div>
388
385
  </div>
389
386
  <button class="remove-btn" onclick="removeCredential('${c.provider}')" ${isCurrent || credentials.length === 1 ? 'disabled title="' + (isCurrent ? 'Cannot remove the method you are currently logged in with' : 'Cannot remove your last login method') + '"' : ''}>
@@ -1,3 +1,89 @@
1
+ /* Color tokens. Every color below is keyed off these variables so the whole
2
+ page can be re-themed by flipping a single set of values — matching how the
3
+ landing page (index.html) and the <power-strip> component theme themselves.
4
+ The light palette is the default; the dark overrides are driven by the
5
+ user's OS color-scheme preference, with a [data-theme] escape hatch so a
6
+ page (or the power strip) can force a theme at runtime. */
7
+ :root {
8
+ --bg: #f9f9f9;
9
+ --surface: #fff;
10
+ --surface-muted: #f8f9fa;
11
+ --surface-alt: #f1f3f4;
12
+ --hover-bg: #f0f0f0;
13
+ --text: #333;
14
+ --text-secondary: #555;
15
+ --text-faint: #666;
16
+ --text-muted: #717171;
17
+ --border: #ddd;
18
+ --border-light: #eee;
19
+ --accent: #ffcc00;
20
+ --accent-hover: #e6b800;
21
+ --accent-text: #826700;
22
+ --accent-soft-bg: #fff7d6;
23
+ --on-accent: #202124;
24
+ --muted-badge-text: #5f6368;
25
+ --danger: #d93025;
26
+ --danger-hover: #ea4335;
27
+ --danger-soft-bg: #fce8e6;
28
+ --disabled-bg: #ccc;
29
+ --avatar-remove-bg: #727579;
30
+ --avatar-remove-icon: #fff;
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ :root:not([data-theme='light']) {
35
+ --bg: #1a1a1a;
36
+ --surface: #2d2d2d;
37
+ --surface-muted: #333;
38
+ --surface-alt: #3c4043;
39
+ --hover-bg: #3c4043;
40
+ --text: #e0e0e0;
41
+ --text-secondary: #c0c0c0;
42
+ --text-faint: #bdc1c6;
43
+ --text-muted: #9aa0a6;
44
+ --border: #5f6368;
45
+ --border-light: #444;
46
+ --accent: #ffcc00;
47
+ --accent-hover: #e6b800;
48
+ --accent-text: #ffcc00;
49
+ --accent-soft-bg: #3a3320;
50
+ --on-accent: #202124;
51
+ --muted-badge-text: #a8adb2;
52
+ --danger: #f28b82;
53
+ --danger-hover: #ee675c;
54
+ --danger-soft-bg: #3c2a28;
55
+ --disabled-bg: #5f6368;
56
+ --avatar-remove-bg: #5f6368;
57
+ --avatar-remove-icon: #e0e0e0;
58
+ }
59
+ }
60
+
61
+ [data-theme='dark'] {
62
+ --bg: #1a1a1a;
63
+ --surface: #2d2d2d;
64
+ --surface-muted: #333;
65
+ --surface-alt: #3c4043;
66
+ --hover-bg: #3c4043;
67
+ --text: #e0e0e0;
68
+ --text-secondary: #c0c0c0;
69
+ --text-faint: #bdc1c6;
70
+ --text-muted: #9aa0a6;
71
+ --border: #5f6368;
72
+ --border-light: #444;
73
+ --accent: #ffcc00;
74
+ --accent-hover: #e6b800;
75
+ --accent-text: #ffcc00;
76
+ --accent-soft-bg: #3a3320;
77
+ --on-accent: #202124;
78
+ --muted-badge-text: #a8adb2;
79
+ --danger: #f28b82;
80
+ --danger-hover: #ee675c;
81
+ --danger-soft-bg: #3c2a28;
82
+ --disabled-bg: #5f6368;
83
+ --avatar-remove-bg: #5f6368;
84
+ --avatar-remove-icon: #e0e0e0;
85
+ }
86
+
1
87
  * {
2
88
  box-sizing: border-box;
3
89
  }
@@ -9,7 +95,8 @@ body {
9
95
  sans-serif;
10
96
  padding: 2rem;
11
97
  margin: 0 auto;
12
- background: #f9f9f9;
98
+ background: var(--bg);
99
+ color: var(--text);
13
100
  }
14
101
 
15
102
  .main-layout,
@@ -68,7 +155,7 @@ body {
68
155
  .nav-link {
69
156
  display: block;
70
157
  padding: 0.75rem 1rem;
71
- color: #555;
158
+ color: var(--text-secondary);
72
159
  text-decoration: none;
73
160
  border-radius: 6px;
74
161
  transition: all 0.2s;
@@ -77,20 +164,20 @@ body {
77
164
  }
78
165
 
79
166
  .nav-link:hover {
80
- background: #f0f0f0;
81
- color: #1a73e8;
167
+ background: var(--hover-bg);
168
+ color: var(--accent-text);
82
169
  }
83
170
 
84
171
  .nav-link.active {
85
- color: #1a73e8;
172
+ color: var(--accent-text);
86
173
  font-weight: 600;
87
- border-left: 3px solid #1a73e8;
174
+ border-left: 3px solid var(--accent-text);
88
175
  border-radius: 0;
89
176
  padding-left: calc(1rem - 3px);
90
177
  }
91
178
 
92
179
  h1.page-subtitle {
93
- color: #666;
180
+ color: var(--text-faint);
94
181
  margin-bottom: 0.25rem;
95
182
  font-size: 1.1rem;
96
183
  text-transform: uppercase;
@@ -101,7 +188,7 @@ h1.page-subtitle {
101
188
  .page-title {
102
189
  font-size: 2.5rem;
103
190
  font-weight: bold;
104
- color: #333;
191
+ color: var(--text);
105
192
  margin-bottom: 0.5rem;
106
193
  white-space: nowrap;
107
194
  overflow: hidden;
@@ -111,7 +198,7 @@ h1.page-subtitle {
111
198
 
112
199
  .subtitle {
113
200
  font-size: 0.75rem;
114
- color: #888;
201
+ color: var(--text-muted);
115
202
  margin-bottom: 2rem;
116
203
  font-family: monospace;
117
204
  display: flex;
@@ -121,7 +208,7 @@ h1.page-subtitle {
121
208
  }
122
209
 
123
210
  section {
124
- background: white;
211
+ background: var(--surface);
125
212
  padding: 1.5rem;
126
213
  border-radius: 8px;
127
214
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@@ -136,28 +223,30 @@ section {
136
223
  display: block;
137
224
  margin-bottom: 0.5rem;
138
225
  font-weight: 500;
139
- color: #555;
226
+ color: var(--text-secondary);
140
227
  }
141
228
 
142
229
  .form-group input,
143
230
  .form-group select {
144
231
  width: 100%;
145
232
  padding: 0.75rem;
146
- border: 1px solid #ddd;
233
+ border: 1px solid var(--border);
147
234
  border-radius: 4px;
148
235
  box-sizing: border-box;
149
236
  font-size: 1rem;
237
+ background: var(--surface);
238
+ color: var(--text);
150
239
  }
151
240
 
152
241
  .form-group input:disabled {
153
- background: #f0f0f0;
154
- color: #888;
242
+ background: var(--surface-muted);
243
+ color: var(--text-muted);
155
244
  }
156
245
 
157
246
  button {
158
247
  padding: 0.75rem 1.5rem;
159
- background: #1a73e8;
160
- color: white;
248
+ background: var(--accent);
249
+ color: var(--on-accent);
161
250
  border: none;
162
251
  border-radius: 4px;
163
252
  cursor: pointer;
@@ -166,21 +255,21 @@ button {
166
255
  }
167
256
 
168
257
  button:hover {
169
- background: #1557b0;
258
+ background: var(--accent-hover);
170
259
  }
171
260
 
172
261
  button.secondary-btn {
173
- background: #fff;
174
- color: #1a73e8;
175
- border: 1px solid #1a73e8;
262
+ background: var(--surface);
263
+ color: var(--accent-text);
264
+ border: 1px solid var(--accent-text);
176
265
  }
177
266
 
178
267
  button.secondary-btn:hover {
179
- background: #f8f9fa;
268
+ background: var(--surface-muted);
180
269
  }
181
270
 
182
271
  button:disabled {
183
- background: #ccc;
272
+ background: var(--disabled-bg);
184
273
  cursor: not-allowed;
185
274
  }
186
275
 
@@ -200,7 +289,7 @@ button:disabled {
200
289
  .back-link {
201
290
  display: inline-block;
202
291
  margin-bottom: 1rem;
203
- color: #1a73e8;
292
+ color: var(--accent-text);
204
293
  text-decoration: none;
205
294
  }
206
295
 
@@ -210,29 +299,29 @@ button:disabled {
210
299
 
211
300
  .remove-btn {
212
301
  background: transparent;
213
- color: #d93025;
214
- border: 1px solid #d93025;
302
+ color: var(--danger);
303
+ border: 1px solid var(--danger);
215
304
  padding: 0.4rem 0.8rem;
216
305
  font-size: 0.85rem;
217
306
  }
218
307
 
219
308
  .remove-btn:hover {
220
- background: #fce8e6;
309
+ background: var(--danger-soft-bg);
221
310
  }
222
311
 
223
312
  .remove-btn:disabled {
224
- background: #fafafa;
225
- border-color: #eee;
226
- color: #999;
313
+ background: var(--surface-muted);
314
+ border-color: var(--border-light);
315
+ color: var(--text-muted);
227
316
  cursor: not-allowed;
228
317
  }
229
318
 
230
319
  .btn-link {
231
320
  display: inline-block;
232
321
  padding: 0.75rem 1rem;
233
- background: white;
234
- color: #1a73e8;
235
- border: 1px solid #1a73e8;
322
+ background: var(--surface);
323
+ color: var(--accent-text);
324
+ border: 1px solid var(--accent-text);
236
325
  border-radius: 4px;
237
326
  text-decoration: none;
238
327
  font-weight: 500;
@@ -241,7 +330,7 @@ button:disabled {
241
330
  }
242
331
 
243
332
  .btn-link:hover {
244
- background: #f8f9fa;
333
+ background: var(--surface-muted);
245
334
  }
246
335
 
247
336
  .remove-image-btn {
@@ -251,9 +340,9 @@ button:disabled {
251
340
  width: 20px;
252
341
  height: 20px;
253
342
  border-radius: 50%;
254
- background: #bdc1c6;
255
- color: white;
256
- border: 2px solid white;
343
+ background: var(--avatar-remove-bg);
344
+ color: var(--avatar-remove-icon);
345
+ border: 2px solid var(--surface);
257
346
  cursor: pointer;
258
347
  display: flex;
259
348
  align-items: center;
@@ -284,7 +373,7 @@ button:disabled {
284
373
  height: 100px;
285
374
  border-radius: 50%;
286
375
  object-fit: cover;
287
- border: 3px solid white;
376
+ border: 3px solid var(--surface);
288
377
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
289
378
  }
290
379
 
@@ -293,7 +382,7 @@ button:disabled {
293
382
  height: 100px;
294
383
  border-radius: 8px;
295
384
  object-fit: cover;
296
- border: 3px solid white;
385
+ border: 3px solid var(--surface);
297
386
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
298
387
  }
299
388
 
@@ -302,20 +391,20 @@ button:disabled {
302
391
  justify-content: space-between;
303
392
  align-items: center;
304
393
  padding: 1rem;
305
- border: 1px solid #eee;
394
+ border: 1px solid var(--border-light);
306
395
  border-radius: 8px;
307
396
  margin-bottom: 0.75rem;
308
397
  }
309
398
 
310
399
  .credential-item.active {
311
- border-color: #1a73e8;
312
- background-color: #e8f0fe;
400
+ border-color: var(--accent-text);
401
+ background-color: var(--accent-soft-bg);
313
402
  }
314
403
 
315
404
  .current-badge {
316
405
  font-size: 0.75rem;
317
- background: #1a73e8;
318
- color: white;
406
+ background: var(--accent);
407
+ color: var(--on-accent);
319
408
  padding: 0.125rem 0.375rem;
320
409
  border-radius: 0.75rem;
321
410
  margin-left: 0.5rem;
@@ -349,13 +438,13 @@ button:disabled {
349
438
  text-decoration: none;
350
439
  font-weight: 500;
351
440
  font-size: 0.875rem;
352
- border: 1px solid #ddd;
353
- color: #333;
441
+ border: 1px solid var(--border);
442
+ color: var(--text);
354
443
  transition: background 0.2s;
355
444
  }
356
445
 
357
446
  .link-account-btn.google:hover {
358
- background: #f8f9fa;
447
+ background: var(--surface-muted);
359
448
  }
360
449
 
361
450
  .link-account-btn.twitch {
@@ -378,7 +467,7 @@ button:disabled {
378
467
  justify-content: space-between;
379
468
  align-items: center;
380
469
  padding: 1rem;
381
- border-bottom: 1px solid #eee;
470
+ border-bottom: 1px solid var(--border-light);
382
471
  }
383
472
 
384
473
  .member-item:last-child {
@@ -398,11 +487,11 @@ button:disabled {
398
487
  border-radius: 50%;
399
488
  object-fit: cover;
400
489
  flex-shrink: 0;
401
- background: #f1f3f4;
490
+ background: var(--surface-alt);
402
491
  display: flex;
403
492
  align-items: center;
404
493
  justify-content: center;
405
- color: #5f6368;
494
+ color: var(--muted-badge-text);
406
495
  }
407
496
 
408
497
  .member-avatar svg {
@@ -436,27 +525,28 @@ button:disabled {
436
525
  font-size: 0.75rem;
437
526
  padding: 0.25rem 0.5rem;
438
527
  border-radius: 1rem;
439
- background: #f1f3f4;
440
- color: #5f6368;
528
+ background: var(--surface-alt);
529
+ color: var(--muted-badge-text);
441
530
  font-weight: 500;
442
531
  }
443
532
 
444
533
  .role-badge.admin {
445
- background: #e8f0fe;
446
- color: #1a73e8;
534
+ background: var(--accent-soft-bg);
535
+ color: var(--accent-text);
447
536
  }
448
537
 
449
538
  .role-select {
450
539
  padding: 0.25rem 0.5rem;
451
540
  border-radius: 4px;
452
- border: 1px solid #ddd;
541
+ border: 1px solid var(--border);
453
542
  font-size: 0.85rem;
454
- background: #fff;
543
+ background: var(--surface);
544
+ color: var(--text);
455
545
  }
456
546
 
457
547
  .role-select:disabled {
458
- background: #f1f3f4;
459
- color: #5f6368;
548
+ background: var(--surface-alt);
549
+ color: var(--muted-badge-text);
460
550
  border-color: transparent;
461
551
  appearance: none;
462
552
  -webkit-appearance: none;
@@ -475,7 +565,7 @@ button:disabled {
475
565
  border: none;
476
566
  padding: 0.25rem;
477
567
  cursor: pointer;
478
- color: #1a73e8;
568
+ color: var(--accent-text);
479
569
  display: flex;
480
570
  align-items: center;
481
571
  justify-content: center;
@@ -484,7 +574,7 @@ button:disabled {
484
574
  }
485
575
 
486
576
  .copy-btn:hover {
487
- background: #f0f0f0;
577
+ background: var(--hover-bg);
488
578
  }
489
579
 
490
580
  .copy-btn svg {
package/src/PowerStrip.ts CHANGED
@@ -31,7 +31,7 @@ export async function injectPowerStrip(response: Response, usersPath: string, pr
31
31
  element.onEndTag((end) => {
32
32
  if (!hasUserPowerStrip) {
33
33
  end.before(
34
- `<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
34
+ `<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; border-radius: 0 0 0 0.3rem;">` +
35
35
  '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
36
36
  '</power-strip>',
37
37
  { html: true },