@startup-api/cloudflare 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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') + '"' : ''}>