heartcode 0.1.15 → 0.1.24

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/src/settings.html CHANGED
@@ -4,55 +4,95 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>HeartCode Settings</title>
7
+ <script>
8
+ // light or dark: chosen on the page (kept in this browser), else as the system is set
9
+ try { const t = localStorage.getItem('heart-theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t } catch {}
10
+ </script>
7
11
  <style>
12
+ /* One accent (green), one cool-gray family; red only for what failed. */
8
13
  :root {
9
- --bg: #f5f6f8; --panel: #ffffff; --panel2: #f9fafb; --text: #1f2328; --muted: #57606a; --faint: #8c959f; --line: #dde2e8;
10
- --brand: #1a7f37; --brand-soft: #e6f4ea; --on-brand: #ffffff; --accent: #0969da; --accent-soft: #e8f1fd; --ok: #1a7f37; --ok-soft: #e6f4ea; --err: #cf222e; --err-soft: #fdecee;
11
- --input: #ffffff; --hover: #eef1f5; --on-accent: #ffffff;
12
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
- --radius: 12px;
14
+ --bg: #f6f6f8; --side: #fbfbfc; --panel: #ffffff; --panel2: #fafafb; --text: #17181c; --muted: #5d616c; --faint: #8d919b;
15
+ --line: #e7e8ec; --line-strong: #d6d8de; --hover: #f2f3f5; --input: #ffffff;
16
+ --accent: #16824c; --accent-soft: #e8f5ee; --on-accent: #ffffff;
17
+ --brand: var(--accent); --brand-soft: var(--accent-soft); --on-brand: var(--on-accent);
18
+ --ok: #1d8a4b; --ok-soft: #e7f5ec; --err: #d1242f; --err-soft: #fdecee;
19
+ --ring: 0 0 0 3px rgba(22, 130, 76, .18);
20
+ --shadow: 0 1px 2px rgba(20, 22, 30, .04), 0 8px 24px rgba(20, 22, 30, .06);
21
+ --shadow-pop: 0 24px 64px rgba(20, 22, 30, .18), 0 2px 6px rgba(20, 22, 30, .06);
22
+ --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
23
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
24
+ --radius: 14px; --radius-s: 8px;
25
+ color-scheme: light;
14
26
  }
27
+ /* dark: as the system is set (unless Light is chosen on the page), or Dark chosen */
15
28
  @media (prefers-color-scheme: dark) {
16
- :root {
17
- --bg: #0d0f13; --panel: #15181e; --panel2: #1a1d24; --text: #e6e8eb; --muted: #9aa0aa; --faint: #6b7280; --line: #272c36;
18
- --brand: #9ece6a; --brand-soft: #1e2a18; --on-brand: #0d0f13; --accent: #7aa2f7; --accent-soft: #1b2438; --ok: #9ece6a; --ok-soft: #1e2a18; --err: #f7768e; --err-soft: #3a1c22;
19
- --input: #0f1116; --hover: #1d212a; --on-accent: #0d0f13;
29
+ :root:not([data-theme=light]) {
30
+ --bg: #0c0d10; --side: #0f1014; --panel: #14151a; --panel2: #17191e; --text: #ececf0; --muted: #a2a6b0; --faint: #6d717c;
31
+ --line: #23252c; --line-strong: #2f323a; --hover: #1c1e24; --input: #101115;
32
+ --accent: #5ccf8a; --accent-soft: rgba(92, 207, 138, .13); --on-accent: #07140d;
33
+ --ok: #62cf8b; --ok-soft: rgba(98, 207, 139, .12); --err: #ff7a7a; --err-soft: rgba(255, 122, 122, .12);
34
+ --ring: 0 0 0 3px rgba(92, 207, 138, .25);
35
+ --shadow: 0 1px 2px rgba(0, 0, 0, .3);
36
+ --shadow-pop: 0 28px 72px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .03);
37
+ color-scheme: dark;
20
38
  }
21
39
  }
40
+ :root[data-theme=dark] {
41
+ --bg: #0c0d10; --side: #0f1014; --panel: #14151a; --panel2: #17191e; --text: #ececf0; --muted: #a2a6b0; --faint: #6d717c;
42
+ --line: #23252c; --line-strong: #2f323a; --hover: #1c1e24; --input: #101115;
43
+ --accent: #5ccf8a; --accent-soft: rgba(92, 207, 138, .13); --on-accent: #07140d;
44
+ --ok: #62cf8b; --ok-soft: rgba(98, 207, 139, .12); --err: #ff7a7a; --err-soft: rgba(255, 122, 122, .12);
45
+ --ring: 0 0 0 3px rgba(92, 207, 138, .25);
46
+ --shadow: 0 1px 2px rgba(0, 0, 0, .3);
47
+ --shadow-pop: 0 28px 72px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .03);
48
+ color-scheme: dark;
49
+ }
22
50
  * { box-sizing: border-box; }
23
- body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
24
- a { color: var(--accent); }
51
+ html { scroll-behavior: smooth; }
52
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
53
+ a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: 2px; }
54
+ a:hover { text-decoration-color: currentColor; }
55
+ ::selection { background: var(--accent-soft); }
25
56
  button, input, select, textarea { font: inherit; color: inherit; }
57
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
26
58
  [hidden] { display: none !important; }
27
59
 
28
60
  /* the app: pages on the left, one page at a time on the right, using the whole width */
29
61
  .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
30
- aside { border-right: 1px solid var(--line); padding: 20px 12px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--panel); display: flex; flex-direction: column; }
31
- .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; padding: 0 10px 20px; }
32
- .brand span { color: var(--brand); font-size: 18px; }
33
- nav a { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; margin-bottom: 2px; }
62
+ aside { border-right: 1px solid var(--line); padding: 22px 14px 16px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--side); display: flex; flex-direction: column; }
63
+ .brand { display: flex; align-items: baseline; gap: 8px; font-weight: 650; font-size: 15px; letter-spacing: -.01em; padding: 0 10px 22px; }
64
+ .brand span { color: var(--accent); font-size: 17px; }
65
+ .brand small { color: var(--faint); font-weight: 500; font-size: 13px; }
66
+ nav a { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius-s); color: var(--muted); text-decoration: none; margin-bottom: 1px; font-weight: 500; transition: background .15s, color .15s; }
34
67
  nav a:hover { background: var(--hover); color: var(--text); }
35
- nav a.on { background: var(--brand-soft); color: var(--text); font-weight: 600; }
36
- nav a .ic { width: 18px; text-align: center; opacity: .8; }
37
- nav a .count { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: normal; }
68
+ nav a.on { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow), inset 0 0 0 1px var(--line); }
69
+ nav a.on::before { content: ''; position: absolute; left: -14px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
70
+ nav a .ic { width: 18px; text-align: center; opacity: .7; font-size: 13px; }
71
+ nav a.on .ic { color: var(--accent); opacity: 1; }
72
+ nav a .count { margin-left: auto; font-size: 11.5px; color: var(--faint); font-weight: 500; font-variant-numeric: tabular-nums; }
38
73
  .using:empty { visibility: hidden; } /* until your settings are in */
39
- .using { margin: auto 4px 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; font-size: 12.5px; background: var(--panel2); }
74
+ .using { margin: auto 0 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; font-size: 12px; background: var(--panel); }
40
75
  .using .k { color: var(--faint); display: block; }
41
- .using b { display: block; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 2px 0; }
42
- main { padding: 32px 40px 64px; width: 100%; max-width: 1640px; min-width: 0; }
76
+ .using b { display: block; font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; margin: 3px 0 2px; color: var(--text); }
77
+ .using a { font-weight: 600; }
78
+ .theme-pick { display: flex; margin-top: 10px; height: 34px; }
79
+ .theme-pick label { flex: 1; justify-content: center; font-size: 12.5px; }
80
+ .using:empty + .theme-pick { margin-top: auto; }
81
+ main { padding: 36px 44px 72px; width: 100%; max-width: 1640px; min-width: 0; }
43
82
  .page { display: none; }
44
- .page.on { display: block; }
45
- .head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
83
+ .page.on { display: block; animation: page-in .22s ease-out; }
84
+ @keyframes page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
85
+ .head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px 24px; margin-bottom: 24px; flex-wrap: wrap; }
46
86
  .disk { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
47
- .disk b { font-size: 22px; }
87
+ .disk b { font-size: 22px; font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
48
88
  .disk .k { color: var(--muted); font-size: 12.5px; }
49
- h1 { font-size: 24px; margin: 0 0 4px; }
50
- .lead { color: var(--muted); margin: 0; max-width: 760px; }
51
- h2 { font-size: 15px; margin: 0 0 4px; }
52
- h3 { font-size: 13px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
53
- .sub { color: var(--muted); margin: 0 0 14px; font-size: 13px; }
89
+ h1 { font-size: 24px; font-weight: 680; letter-spacing: -.025em; line-height: 1.2; margin: 0 0 6px; }
90
+ .lead { color: var(--muted); margin: 0; max-width: 68ch; font-size: 13.5px; text-wrap: pretty; }
91
+ h2 { font-size: 15px; font-weight: 620; letter-spacing: -.01em; margin: 0 0 4px; }
92
+ h3 { font-size: 12.5px; font-weight: 600; margin: 0 0 8px; color: var(--muted); }
93
+ .sub { color: var(--muted); margin: 0 0 16px; font-size: 13px; text-wrap: pretty; }
54
94
 
55
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; }
95
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px 24px 24px; }
56
96
  .stack { display: flex; flex-direction: column; gap: 18px; }
57
97
  /* two columns: the work on the left, a panel on the right */
58
98
  .split { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 24px; align-items: start; }
@@ -65,25 +105,26 @@
65
105
  .cols > .stack { display: flex; flex-direction: column; }
66
106
  .cols > .stack > .card:last-child { flex: 1; } /* both columns end at the same line */
67
107
  /* two or three choices in one control (Command | URL) */
68
- .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; height: 38px; }
69
- .seg label { display: flex; align-items: center; padding: 0 14px; cursor: pointer; color: var(--muted); }
70
- .seg label + label { border-left: 1px solid var(--line); }
71
- .seg label.on { background: var(--accent-soft); color: var(--text); font-weight: 600; }
108
+ .seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: 10px; background: var(--hover); height: 36px; }
109
+ .seg label { display: flex; align-items: center; padding: 0 12px; border-radius: 7px; cursor: pointer; color: var(--muted); font-weight: 500; font-size: 13px; transition: background .15s, color .15s; }
110
+ .seg label:hover { color: var(--text); }
111
+ .seg label.on { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow), inset 0 0 0 1px var(--line); }
72
112
  .seg input { position: absolute; opacity: 0; width: 0; }
73
113
  /* a list on the left, what's chosen on the right */
74
114
  .md { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 20px; align-items: start; }
75
115
  .md .list { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; position: sticky; top: 24px; max-height: calc(100vh - 170px); display: flex; flex-direction: column; }
76
116
  .md .list .top { padding: 12px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; }
77
117
  .md .list .items { overflow-y: auto; }
78
- .md .group { padding: 10px 14px 4px; font-size: 11px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .05em; }
79
- .md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px 14px; border-left: 3px solid transparent; cursor: pointer; }
118
+ .md .group { padding: 14px 16px 6px; font-size: 12px; font-weight: 600; color: var(--faint); }
119
+ .md .it { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; background: none; padding: 9px 16px; box-shadow: inset 3px 0 0 transparent; cursor: pointer; transition: background .12s; }
80
120
  .md .it:hover { background: var(--hover); }
81
- .md .it.on { background: var(--accent-soft); border-left-color: var(--accent); }
121
+ .md .it.on { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
82
122
  .md .it .t { display: flex; gap: 6px; align-items: center; font-weight: 600; font-size: 13px; word-break: break-all; }
83
123
  .md .it .d { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
84
124
  .md .detail { min-width: 0; }
85
125
  .md.solo { grid-template-columns: minmax(0, 1fr); } /* nothing to list: the panel gets the width */
86
126
  .md.solo .list { display: none; }
127
+ .md.solo .detail > .card { height: auto !important; }
87
128
  /* a long list (the prompts): list and editor fill the screen's height, like an editor app */
88
129
  /* a list (or tabs) and its editor fill the screen below the page's title, like an editor app;
89
130
  the height is set from where it starts (fillHeight) */
@@ -98,51 +139,60 @@
98
139
  .md.tabs .list { position: static; max-height: none; background: none; border: 0; overflow: visible; }
99
140
  .md.tabs .items { display: flex; flex-wrap: wrap; gap: 8px; overflow: visible; }
100
141
  .md.tabs .group { display: none; }
101
- .md.tabs .it { width: auto; border: 1px solid var(--line); border-radius: 999px; padding: 7px 16px; background: var(--panel); }
102
- .md.tabs .it.on { border-color: var(--accent); background: var(--accent-soft); }
142
+ .md.tabs .it { width: auto; border: 1px solid var(--line); border-radius: 10px; padding: 7px 14px; background: var(--panel); box-shadow: none; }
143
+ .md.tabs .it:hover { border-color: var(--line-strong); }
144
+ .md.tabs .it.on { border-color: var(--accent); background: var(--accent-soft); box-shadow: none; }
103
145
  .md.tabs .it .d { display: none; }
104
146
  .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); flex: none; }
105
147
 
106
- label.f { display: block; font-size: 12.5px; font-weight: 600; margin: 14px 0 6px; }
107
- .hint { color: var(--faint); font-size: 12.5px; margin-top: 6px; }
148
+ label.f { display: block; font-size: 12.5px; font-weight: 600; margin: 16px 0 6px; }
149
+ .hint { color: var(--faint); font-size: 12.5px; margin-top: 7px; text-wrap: pretty; }
108
150
  input[type=text], input[type=password], input[type=number], input[type=search], select, textarea {
109
- width: 100%; padding: 8px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--input);
151
+ width: 100%; padding: 8px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius-s); background: var(--input);
152
+ transition: border-color .15s, box-shadow .15s;
110
153
  }
111
- input[type=text], input[type=password], input[type=number], input[type=search], select { height: 38px; }
112
- textarea { font-family: var(--mono); font-size: 12.5px; resize: vertical; tab-size: 2; line-height: 1.55; display: block; }
154
+ input[type=text], input[type=password], input[type=number], input[type=search], select { height: 36px; }
155
+ input::placeholder, textarea::placeholder { color: var(--faint); }
156
+ input[type=text]:hover, input[type=password]:hover, input[type=number]:hover, input[type=search]:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--line-strong) 60%, var(--muted)); }
157
+ select { appearance: none; padding-right: 34px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%238d919b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5l3 3 3-3'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
158
+ textarea { font-family: var(--mono); font-size: 12.5px; resize: vertical; tab-size: 2; line-height: 1.6; display: block; }
113
159
  textarea.editor { min-height: 96px; overflow-y: auto; } /* grows with its text (grow()), up to most of the screen */
114
- input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
115
- button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; white-space: nowrap; }
116
- button:hover:not(:disabled) { border-color: var(--accent); }
160
+ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
161
+ input[type=checkbox] { accent-color: var(--accent); }
162
+ button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 14px; line-height: 18px; border-radius: var(--radius-s); border: 1px solid var(--line-strong); background: var(--panel); cursor: pointer; white-space: nowrap; font-weight: 500; transition: background .15s, border-color .15s, color .15s, transform .08s, filter .15s; }
163
+ button:hover:not(:disabled) { background: var(--hover); }
164
+ button:active:not(:disabled) { transform: translateY(1px); }
117
165
  button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
118
- button.primary:hover:not(:disabled) { filter: brightness(1.08); }
166
+ button.primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.07); }
119
167
  button.danger { color: var(--err); }
120
- button.small { padding: 3px 10px; font-size: 12.5px; }
168
+ button.danger:hover:not(:disabled) { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
169
+ button.small { padding: 4px 10px; font-size: 12.5px; }
121
170
  button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
122
- button:disabled { opacity: .4; cursor: default; }
171
+ button.ghost:hover:not(:disabled) { color: var(--text); background: var(--hover); }
172
+ button:disabled { opacity: .45; cursor: default; }
123
173
  .row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
124
174
  .row > .grow { flex: 1; min-width: 180px; }
125
175
  .actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
126
176
  .actions .spacer { flex: 1; }
127
177
  .mono { font-family: var(--mono); font-size: 12.5px; }
128
178
  .muted { color: var(--muted); }
129
- .tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 10px; background: var(--hover); color: var(--muted); }
130
- .tag.brand { background: var(--brand); color: var(--on-brand); }
179
+ .tag { display: inline-block; white-space: nowrap; font-size: 11px; font-weight: 600; line-height: 18px; padding: 0 7px; border-radius: 6px; background: var(--hover); color: var(--muted); font-variant-numeric: tabular-nums; }
180
+ .tag.brand { background: var(--accent-soft); color: var(--accent); }
131
181
  .tag.ok { background: var(--ok-soft); color: var(--ok); }
132
182
  .tag.err { background: var(--err-soft); color: var(--err); }
133
- .empty { text-align: center; padding: 26px 16px; color: var(--muted); }
183
+ .empty { text-align: center; padding: 36px 16px; color: var(--muted); }
134
184
  .empty b { display: block; color: var(--text); font-size: 15px; margin-bottom: 4px; }
135
185
 
136
186
  /* first start: the steps, then adding a provider */
137
187
  .steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 22px; }
138
- .step { display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--muted); font-size: 13px; }
139
- .step .num { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; background: var(--hover); color: var(--muted); flex: none; }
188
+ .step { display: flex; gap: 12px; align-items: center; padding: 15px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--muted); font-size: 13px; }
189
+ .step .num { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; font-weight: 700; background: var(--hover); color: var(--muted); flex: none; }
140
190
  .step b { display: block; color: var(--text); }
141
191
  .step.done .num { background: var(--ok-soft); color: var(--ok); }
142
192
  .step.now { border-color: var(--brand); }
143
193
  .step.now .num { background: var(--brand); color: var(--on-brand); }
144
194
  .presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 10px; }
145
- .preset { text-align: left; padding: 12px 14px; border-radius: 10px; }
195
+ .preset { display: block; text-align: left; padding: 12px 14px; border-radius: 12px; }
146
196
  .preset b { display: block; }
147
197
  .preset span { color: var(--muted); font-size: 12px; }
148
198
  .preset.on { border-color: var(--accent); background: var(--accent-soft); }
@@ -151,7 +201,7 @@
151
201
  .head-actions { display: flex; gap: 8px; align-items: center; }
152
202
  .work { display: flex; flex-direction: column; }
153
203
  .work > .card { flex: 1; padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
154
- .cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
204
+ .cat-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
155
205
  .cat-top input[type=search] { flex: 1; min-width: 140px; }
156
206
  .cat-top select { flex: none; width: auto; padding-right: 30px; }
157
207
  .cat-top button { flex: none; }
@@ -159,7 +209,7 @@
159
209
  .cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
160
210
  .cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick, .free-only input { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
161
211
  /* what to do with the ticked models: at the bottom of the list, only while some are ticked */
162
- .picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--accent-soft); font-size: 13px; }
212
+ .picks { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-top: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); background: var(--accent-soft); font-size: 13px; animation: page-in .18s ease-out; }
163
213
  .picks .n { font-weight: 600; margin-right: auto; }
164
214
  .prov-h { cursor: pointer; }
165
215
  .prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; }
@@ -167,36 +217,47 @@
167
217
  section.collapsed .prov-h .fold { transform: rotate(-90deg); }
168
218
  section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
169
219
  .cat-list { flex: 1; overflow-y: auto; }
170
- .prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
220
+ .prov-h { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 10px 16px; min-height: 52px; background: var(--panel2); border-bottom: 1px solid var(--line); transition: background .12s; }
221
+ .prov-h:hover { background: color-mix(in srgb, var(--panel2) 70%, var(--hover)); }
171
222
  .prov-h .who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
172
- .prov-h .name { font-weight: 700; font-size: 15px; }
173
- .prov-h .meta { font-size: 12px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
223
+ .prov-h .name { font-weight: 650; font-size: 14.5px; letter-spacing: -.01em; }
224
+ .prov-h .meta { font-family: var(--mono); font-size: 11.5px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-left: 4px; }
174
225
  .prov-h .meta .mono { font-size: 12px; }
175
226
  .rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
176
- .mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; }
177
- .mrow:hover { background: var(--hover); }
227
+ .mrow { break-inside: avoid; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 9px 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: none; cursor: pointer; min-width: 0; font-weight: 400; transform: none !important; }
228
+ .mrow:hover:not(:disabled) { background: transparent; }
178
229
  .mrow.flash { animation: flash 1.6s ease-out; }
179
230
  @keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
180
- .mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
231
+ .mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
181
232
  .rows > .mrow, .rows > .mwrap { height: 56px; } /* one height: a long list only draws what's in view */
182
233
  .mwrap .mrow { height: 100%; }
183
234
  .mrow .can { display: block; margin-top: 2px; color: var(--muted); font-family: system-ui, sans-serif; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; }
184
- .mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
235
+ .mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; opacity: .55; transition: opacity .12s; }
236
+ .mwrap:hover .mrow .add, .mrow:focus-visible .add { opacity: 1; }
185
237
  .mrow.taken .id { color: var(--muted); }
186
- .mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
238
+ .mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); transition: background .12s; }
239
+ .mwrap:hover { background: var(--hover); }
187
240
  .mwrap .mrow { border-bottom: 0; }
188
241
  .mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
189
- .mwrap .try { flex: none; margin-right: 12px; }
242
+ /* a row's buttons: quiet until the row is pointed at */
243
+ .mwrap .try { flex: none; margin-right: 12px; border-color: transparent; background: transparent; color: var(--muted); }
244
+ .mwrap:hover .try { border-color: var(--line-strong); background: var(--panel); color: var(--text); }
190
245
  .prov-h .by-name { display: flex; gap: 6px; flex: none; }
191
- .prov-h .by-name input { width: 200px; height: 28px; padding: 0 8px; font-size: 12.5px; font-family: var(--mono); }
246
+ .prov-h .sw { margin-top: 0; }
247
+ section.off .rows, section.off .prov-h .who { opacity: .45; }
248
+ .mtable li.off > :not(.grip, input, button) { opacity: .45; }
249
+ .prov-h .by-name input { width: 190px; height: 28px; padding: 0 9px; font-size: 12px; font-family: var(--mono); background: transparent; }
192
250
  .prov-h .by-name button, .prov-h > button.small { height: 28px; }
193
251
  .hello-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
194
- .hello-cols pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; max-height: 55vh; overflow: auto; }
252
+ .hello-cols pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; line-height: 1.55; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; max-height: 55vh; overflow: auto; }
195
253
  #hello-status.ok { color: var(--ok); }
196
254
  #hello-status.bad { color: var(--err); }
255
+ dialog#gone { width: min(560px, 92vw); }
256
+ dialog#gone h2 { color: var(--err); font-family: inherit; font-size: 18px; }
257
+ dialog#gone::backdrop { background: rgba(0, 0, 0, .75); }
197
258
  @media (max-width: 860px) { .hello-cols { grid-template-columns: 1fr; } }
198
- #hellos-list { max-height: 60vh; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
199
- #hellos-list button { display: flex; gap: 12px; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; padding: 9px 12px; font-size: 12.5px; }
259
+ #hellos-list { max-height: 60vh; overflow: auto; border: 1px solid var(--line); border-radius: 12px; }
260
+ #hellos-list button { display: flex; justify-content: flex-start; gap: 12px; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; padding: 10px 14px; font-size: 12.5px; font-weight: 400; }
200
261
  #hellos-list button:last-child { border-bottom: 0; }
201
262
  #hellos-list .id { flex: none; width: 40%; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
202
263
  #hellos-list .said { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -216,10 +277,10 @@
216
277
  .reqs { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: 420px; }
217
278
  .req-list { max-height: 70vh; overflow-y: auto; border-right: 1px solid var(--line); }
218
279
  .req-detail { max-height: 70vh; overflow-y: auto; padding: 16px 18px; min-width: 0; }
219
- .rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; background: none; padding: 10px 14px; cursor: pointer; }
220
- .rq:hover { background: var(--hover); }
221
- .rq.on { background: var(--accent-soft); border-left-color: var(--accent); }
222
- .rq.failed { border-left-color: var(--err); }
280
+ .rq { display: block; width: 100%; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); box-shadow: inset 3px 0 0 transparent; background: none; padding: 11px 16px; cursor: pointer; font-weight: 400; transform: none !important; }
281
+ .rq:hover:not(:disabled) { background: var(--hover); }
282
+ .rq.on, .rq.on:hover:not(:disabled) { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
283
+ .rq.failed { box-shadow: inset 3px 0 0 var(--err); }
223
284
  .rq.failed.on { background: var(--err-soft); }
224
285
  .rq .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
225
286
  .rq .m { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -228,7 +289,7 @@
228
289
  .kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 8px 0 14px; }
229
290
  .kv dt { color: var(--muted); }
230
291
  .kv dd { margin: 0; overflow-wrap: anywhere; }
231
- .req-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 6px 0 12px; max-height: 360px; overflow: auto; }
292
+ .req-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; line-height: 1.55; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 6px 0 14px; max-height: 360px; overflow: auto; }
232
293
  .req-detail details { margin: 4px 0; }
233
294
  .req-detail summary { cursor: pointer; font-size: 13px; color: var(--muted); }
234
295
  .req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
@@ -241,15 +302,18 @@
241
302
  .olist .test button { flex: none; }
242
303
  .olist .test.ok { color: var(--ok); }
243
304
  .olist .test.warn { color: var(--accent); }
244
- dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
245
- dialog::backdrop { background: rgba(0, 0, 0, .55); }
246
- dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
305
+ dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); color: var(--text); padding: 26px 28px 24px; box-shadow: var(--shadow-pop); }
306
+ dialog[open] { animation: pop-in .18s cubic-bezier(.2, .8, .3, 1); }
307
+ @keyframes pop-in { from { opacity: 0; transform: translateY(6px) scale(.985); } to { opacity: 1; transform: none; } }
308
+ dialog::backdrop { background: rgba(8, 9, 12, .55); backdrop-filter: blur(3px); }
309
+ dialog h2 { margin: 0 0 6px; font-size: 18px; font-weight: 650; letter-spacing: -.015em; overflow-wrap: anywhere; }
247
310
  dialog#ms .opt:last-of-type { border-bottom: 0; }
248
311
  #pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
249
- #pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
312
+ #pr-list li { counter-increment: rank; padding: 10px 14px; margin-bottom: 6px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel2); cursor: grab; user-select: none; font-weight: 500; transition: border-color .12s; }
313
+ #pr-list li:hover { border-color: var(--accent); }
250
314
  #pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
251
315
  #pr-list li.dragging { opacity: .4; }
252
- dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
316
+ dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
253
317
  .olist .test.failed { color: var(--err); }
254
318
  .olist .test.testing, .olist .test.waiting { color: var(--accent); }
255
319
  .olist .test button { padding: 0 7px; font-size: 11px; }
@@ -274,9 +338,9 @@
274
338
  body.sorting, body.sorting * { cursor: grabbing !important; }
275
339
  /* Your models: a table, one row a model */
276
340
  .mt-card { padding: 0; }
277
- .mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 58px; }
341
+ .mt-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 60px; }
278
342
  .mt-top .sorts { flex: 1 1 320px; min-width: 0; color: var(--muted); font-size: 13px; } /* too narrow beside the buttons: a line of its own */
279
- .mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
343
+ .mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); border-radius: 8px; padding: 1px 3px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
280
344
  .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
281
345
  .mt-scroll { overflow-x: auto; }
282
346
  .mt { min-width: 1116px; }
@@ -290,7 +354,7 @@
290
354
  .mt-outcomes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
291
355
  .mt-outcomes > span:first-child { margin-right: 4px; }
292
356
  /* a test result: what it was and how many (a click selects them), and ↻ to test them again */
293
- .pill { display: inline-flex; align-items: stretch; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); overflow: hidden; font-size: 12px; line-height: 1; }
357
+ .pill { display: inline-flex; align-items: stretch; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); overflow: hidden; font-size: 12px; line-height: 1; }
294
358
  .pill button { border: 0; border-radius: 0; background: none; padding: 5px 8px; display: inline-flex; align-items: center; gap: 5px; }
295
359
  .pill button:hover:not(:disabled) { background: var(--hover); }
296
360
  .pill .what { font-weight: 600; }
@@ -299,30 +363,36 @@
299
363
  .pill .n { color: var(--muted); font-variant-numeric: tabular-nums; }
300
364
  .pill .again { border-left: 1px solid var(--line); padding: 5px 6px; color: var(--muted); }
301
365
  .pill .again:hover:not(:disabled) { color: var(--text); }
302
- .pill.on { border-color: var(--brand); background: var(--brand-soft); }
303
- .pill.on .again { border-left-color: var(--brand); }
366
+ .pill.on { border-color: var(--accent); background: var(--accent-soft); }
367
+ .pill.on .again { border-left-color: color-mix(in srgb, var(--accent) 40%, transparent); }
304
368
  .mt-h, .mtable li { display: grid; grid-template-columns: 16px 20px 44px minmax(150px, 3fr) minmax(70px, 1.1fr) 66px 66px 42px 60px 50px 54px 60px minmax(166px, 1.5fr) minmax(120px, 1.3fr) 28px; align-items: center; column-gap: 6px; padding: 8px 16px; }
305
- .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
306
- .mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
369
+ .mtable .src, .mt-h > :nth-child(13) { padding-left: 12px; } /* room after the right-aligned prices */
370
+ .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 500; color: var(--faint); padding-top: 10px; padding-bottom: 10px; background: var(--panel2); }
371
+ .mt-h button { display: inline; border: 0; background: none !important; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; transform: none !important; }
372
+ .mt-h button.num { text-align: right; }
307
373
  .mt-h button:hover { color: var(--text); }
308
- .mt-h button.on { color: var(--brand); font-weight: 700; }
374
+ .mt-h button.on { color: var(--accent); font-weight: 650; }
309
375
  .mt-h .num, .mtable .num { text-align: right; }
310
376
  .mtable { list-style: none; margin: 0; padding: 0; }
311
- .mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; }
377
+ .mtable li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; position: relative; font-variant-numeric: tabular-nums; transition: background .12s; }
378
+ .mtable li.in:hover { background: var(--hover); }
312
379
  .mtable li:last-child { border-bottom: 0; }
313
380
  .mtable li.in { user-select: none; transition: transform .16s ease, box-shadow .16s ease; }
314
381
  .mtable.drag li.in { cursor: grab; }
315
- .mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: 0 10px 28px rgba(0, 0, 0, .35), 0 0 0 2px var(--brand); }
382
+ .mtable li.in.lifted { transition: none; z-index: 2; cursor: grabbing; box-shadow: var(--shadow-pop), 0 0 0 2px var(--accent); }
316
383
  .mtable .grip { color: var(--faint); touch-action: none; }
317
384
  .mtable:not(.drag) .grip { visibility: hidden; }
318
- .mtable .n { font-weight: 800; color: var(--brand); }
385
+ .mtable .n { font-weight: 650; color: var(--faint); }
386
+ .mtable li:first-child .n { color: var(--accent); }
319
387
  .mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
320
388
  .mtable .p { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
321
389
  .mtable .yes { color: var(--ok); }
322
390
  .mtable .no { color: var(--faint); }
323
391
  .mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
324
392
  .mtable .src.warn { color: var(--accent); }
325
- .mtable button { padding: 1px 7px; font-size: 11.5px; }
393
+ .mtable button { padding: 2px 8px; font-size: 11.5px; border-color: transparent; background: transparent; color: var(--muted); }
394
+ .mtable li:hover button, .mtable li.picked button { border-color: var(--line-strong); background: var(--panel); color: var(--text); }
395
+ .mtable li:hover button.danger { color: var(--err); }
326
396
  .mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
327
397
  .mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
328
398
  .mtable .try.ok { color: var(--ok); }
@@ -347,28 +417,28 @@
347
417
  .opt:first-child { padding-top: 0; }
348
418
  .opt .t { flex: 1; min-width: 0; }
349
419
  .opt b { display: block; }
350
- .opt span { color: var(--muted); font-size: 13px; }
420
+ .opt span { color: var(--muted); font-size: 13px; line-height: 1.45; }
351
421
  .sw { position: relative; width: 38px; height: 22px; flex: none; margin-top: 2px; }
352
422
  .sw input { opacity: 0; width: 0; height: 0; position: absolute; }
353
- .sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line); transition: background .15s; }
354
- .sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: transform .15s; }
355
- .sw input:checked + i { background: var(--ok); }
423
+ .sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line-strong); transition: background .18s; cursor: pointer; }
424
+ .sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .18s cubic-bezier(.3, .7, .4, 1); }
425
+ .sw input:checked + i { background: var(--accent); }
356
426
  .sw input:checked + i::after { transform: translateX(16px); }
357
427
  .sw input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
358
428
 
359
429
  /* choices as cards (permissions, colors) */
360
430
  .choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
361
- .choice { position: relative; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; background: var(--panel); }
362
- .choice:hover { border-color: var(--accent); }
363
- .choice.on { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
431
+ .choice { position: relative; border: 1px solid var(--line-strong); border-radius: 12px; padding: 13px 15px; cursor: pointer; background: var(--panel); transition: border-color .15s, box-shadow .15s, background .15s; }
432
+ .choice:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--line-strong)); }
433
+ .choice.on { border-color: var(--accent); background: var(--accent-soft); box-shadow: var(--ring); }
364
434
  .choice b { display: block; }
365
- .choice span { color: var(--muted); font-size: 12.5px; }
435
+ .choice span { display: block; margin-top: 2px; color: var(--muted); font-size: 12.5px; line-height: 1.45; }
366
436
  .choice input { position: absolute; opacity: 0; }
367
437
  .swatch { display: flex; gap: 3px; margin-bottom: 8px; }
368
438
  .swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid var(--line); }
369
439
 
370
440
  /* a model's tooltip: shown at once on hover (or keyboard focus), next to the row */
371
- #tip { position: fixed; z-index: 30; max-width: 380px; padding: 12px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 10px 30px rgba(0, 0, 0, .28); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .08s; }
441
+ #tip { position: fixed; z-index: 30; max-width: 380px; padding: 13px 15px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow-pop); font-size: 12.5px; pointer-events: none; opacity: 0; transition: opacity .08s; }
372
442
  #tip.show { opacity: 1; }
373
443
  #tip .tid { font-family: var(--mono); font-weight: 700; font-size: 13px; word-break: break-all; }
374
444
  #tip .tname { color: var(--muted); margin-top: 1px; }
@@ -377,14 +447,15 @@
377
447
  #tip dd { margin: 0; }
378
448
  #tip .tdesc { color: var(--muted); margin-top: 10px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
379
449
  #tip .thint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); color: var(--accent); font-weight: 600; }
380
- #toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); padding: 10px 18px; border-radius: 10px; background: var(--text); color: var(--bg); opacity: 0; transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
381
- #toast.show { opacity: 1; }
450
+ #toast { position: fixed; bottom: 24px; left: 50%; transform: translate(-50%, 8px); padding: 10px 18px; border-radius: 12px; background: var(--text); color: var(--bg); font-weight: 500; box-shadow: var(--shadow-pop); opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
451
+ #toast.show { opacity: 1; transform: translate(-50%, 0); }
382
452
  #toast.err { background: var(--err); color: #fff; }
383
453
  /* what's running (tests, loading): a strip at the top of the panel it's about, updated in place */
384
454
  .job-slot:empty { display: none; }
385
455
  .job-slot.solo { margin: -6px 0 18px; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } /* on its own, under a page's title */
386
456
  .job-slot.solo .job:last-child { border-bottom: 0; }
387
457
  .job { padding: 10px 16px 0; border-bottom: 1px solid var(--line); background: var(--panel2); font-size: 12.5px; }
458
+ .job .bar i, .job .spin { color: var(--accent); }
388
459
  .job .top { display: flex; align-items: center; gap: 10px; min-height: 24px; }
389
460
  .job .spin { flex: none; color: var(--brand); }
390
461
  .job .what { font-weight: 600; white-space: nowrap; }
@@ -412,7 +483,9 @@
412
483
  .brand { padding: 0 0 8px; }
413
484
  nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }
414
485
  nav a { flex: none; margin: 0; }
486
+ nav a.on::before { display: none; } /* a strip of tabs: the highlighted one is enough */
415
487
  nav a .count, .using { display: none; }
488
+ .theme-pick { margin: 6px 0 4px; max-width: 280px; }
416
489
  main { padding: 20px 16px 60px; }
417
490
  .steps { grid-template-columns: 1fr; }
418
491
  .choices { grid-template-columns: 1fr; }
@@ -429,7 +502,7 @@
429
502
  <body>
430
503
  <div class="app">
431
504
  <aside>
432
- <div class="brand"><span>♥</span> HeartCode settings</div>
505
+ <div class="brand"><span>♥</span> HeartCode <small>settings</small></div>
433
506
  <nav id="nav">
434
507
  <a href="#providers" data-page="providers"><span class="ic">◉</span> Providers &amp; models <span class="count" id="n-providers"></span></a>
435
508
  <a href="#models" data-page="models"><span class="ic">☰</span> Your models <span class="count" id="n-mine"></span></a>
@@ -441,6 +514,9 @@
441
514
  <a href="#files" data-page="files"><span class="ic">≡</span> Advanced files</a>
442
515
  </nav>
443
516
  <div class="using" id="using"></div>
517
+ <div class="seg theme-pick" id="theme-pick" role="radiogroup" aria-label="Light or dark">
518
+ <label><input type="radio" name="theme-pick" value="system">System</label><label><input type="radio" name="theme-pick" value="light">Light</label><label><input type="radio" name="theme-pick" value="dark">Dark</label>
519
+ </div>
444
520
  </aside>
445
521
 
446
522
  <main>
@@ -448,7 +524,7 @@
448
524
  <!-- Providers & models -->
449
525
  <section class="page" id="page-providers">
450
526
  <div class="head">
451
- <div><h1>Providers &amp; models</h1><p class="lead">Click a model to add it to your list, or tick several and add them at once. HeartCode uses your list in order: #1, then the next when one fails.</p></div>
527
+ <div><h1>Providers &amp; models</h1><p class="lead">Click a model to add it to your list, or tick several. HeartCode tries your list in order: #1 first, the next when one fails.</p></div>
452
528
  <div class="head-actions"><button id="p-mine" type="button"></button><button class="primary" id="p-open">+ Add a provider</button></div>
453
529
  </div>
454
530
  <div class="steps" id="steps"></div>
@@ -471,7 +547,7 @@
471
547
 
472
548
  <!-- Your models: all at once, sortable -->
473
549
  <section class="page" id="page-models">
474
- <div class="head"><div><h1>Your models</h1><p class="lead">Every model in your list, with what it can do. Click a column to sort by it, then more columns to break ties; click again to flip, a third time to drop it. Save the order you see, or drag a row: that saves the order you see with your change, and you go on from there.</p></div></div>
550
+ <div class="head"><div><h1>Your models</h1><p class="lead">Every model HeartCode can use, and what each can do. Drag a row to reorder, or sort by columns (click more to break ties) and save the order you see.</p></div></div>
475
551
  <div class="card mt-card" id="mt"></div>
476
552
  </section>
477
553
 
@@ -553,7 +629,7 @@
553
629
  </div>
554
630
  <div class="card">
555
631
  <h2>Voice input</h2>
556
- <p class="sub">Ctrl+R in HeartCode. Empty: whisper-cli on this machine. Or a provider's transcription API: <span class="mono">{ "provider": "groq", "model": "whisper-large-v3" }</span>; add <span class="mono">"language": "auto"</span> for other languages.</p>
632
+ <p class="sub">/voice in HeartCode. Empty: whisper-cli on this machine. Or a provider's transcription API: <span class="mono">{ "provider": "groq", "model": "whisper-large-v3" }</span>; add <span class="mono">"language": "auto"</span> for other languages.</p>
557
633
  <textarea id="v-text" spellcheck="false" rows="3" placeholder="(whisper-cli on this machine)"></textarea>
558
634
  <div class="actions"><button class="primary" id="v-save">Save voice</button></div>
559
635
  </div>
@@ -642,6 +718,12 @@
642
718
  <div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
643
719
  </dialog>
644
720
  <dialog id="k-form" aria-labelledby="k-title"></dialog>
721
+ <dialog id="gone" aria-labelledby="gone-title">
722
+ <h2 id="gone-title">Disconnected</h2>
723
+ <p class="sub">HeartCode's settings page was turned off, so nothing here can be changed or saved now.</p>
724
+ <p class="sub">To open it again, type <b>/settings</b> in HeartCode, or run <b>heart settings</b> in a terminal: it opens a new page (this one can't reconnect, its key is gone).</p>
725
+ <div class="actions"><span class="spacer"></span><button id="gone-again" type="button">Try again</button></div>
726
+ </dialog>
645
727
  <dialog id="hello" aria-labelledby="hello-title">
646
728
  <h2 id="hello-title"></h2>
647
729
  <p class="sub" id="hello-said"></p>
@@ -669,6 +751,20 @@
669
751
  <div id="tip" role="tooltip"></div>
670
752
  <script>
671
753
  const token = new URLSearchParams(location.search).get('t')
754
+ // Light, dark, or as the system is set: kept in this browser (the one thing the page keeps itself)
755
+ {
756
+ const pick = document.getElementById('theme-pick')
757
+ const now = () => document.documentElement.dataset.theme || 'system'
758
+ const show = () => pick.querySelectorAll('label').forEach((l) => { const i = l.querySelector('input'); i.checked = i.value === now(); l.classList.toggle('on', i.checked) })
759
+ pick.addEventListener('change', (e) => {
760
+ const v = e.target.value
761
+ if (v === 'system') delete document.documentElement.dataset.theme
762
+ else document.documentElement.dataset.theme = v
763
+ try { v === 'system' ? localStorage.removeItem('heart-theme') : localStorage.setItem('heart-theme', v) } catch {}
764
+ show()
765
+ })
766
+ show()
767
+ }
672
768
  const $ = (id) => document.getElementById(id)
673
769
  const h = (tag, props = {}, ...kids) => {
674
770
  const el = document.createElement(tag)
@@ -724,8 +820,22 @@ function fillSlot(slot) {
724
820
  }
725
821
  return slot
726
822
  }
823
+ // The settings server turned off (or this page's key no longer works there): the page says so and
824
+ // is blocked, nothing can be changed. Still there (a moment's network trouble): it goes on.
825
+ const reachable = () => fetch(`/?t=${token}`, { cache: 'no-store' }).then((r) => r.ok, () => false)
826
+ async function checkGone() {
827
+ if (await reachable()) return $('gone').open && $('gone').close()
828
+ if ($('gone').open) return
829
+ for (const d of document.querySelectorAll('dialog[open]')) d.close()
830
+ $('gone').showModal()
831
+ }
832
+ $('gone').addEventListener('cancel', (e) => e.preventDefault()) // Esc doesn't close it
833
+ $('gone-again').onclick = checkGone
727
834
  async function api(path, body) {
728
- const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) })
835
+ const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }).catch((e) => {
836
+ checkGone()
837
+ throw new Error("the settings page is off: HeartCode isn't listening")
838
+ })
729
839
  const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
730
840
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
731
841
  return data
@@ -735,6 +845,7 @@ const DOING = {
735
845
  '/api/provider': 'Checking the address and saving the provider',
736
846
  '/api/provider/delete': 'Deleting the provider',
737
847
  '/api/provider/list': 'Saving the model list setting',
848
+ '/api/provider/on': 'Switching the provider',
738
849
  '/api/order': 'Saving your models',
739
850
  '/api/models-dev': null, // its own strip
740
851
  }
@@ -1228,9 +1339,10 @@ function drawProviders(s) {
1228
1339
  $('steps').hidden = order.length > 0 // the first start only
1229
1340
  $('p-work').hidden = none
1230
1341
  // your list, at the top: how many, and the way to its page (order, tests, what to do when one fails)
1231
- $('p-mine').hidden = !order.length
1232
- $('p-mine').textContent = `Your models (${order.length}) →`
1233
- $('p-mine').title = order.length ? `#1: ${order[0].provider} · ${order[0].model}. Open the Your models page: the order, tests, and what happens when one fails` : ''
1342
+ const shown = shownOrder()
1343
+ $('p-mine').hidden = !shown.length
1344
+ $('p-mine').textContent = `Your models (${shown.length}) →`
1345
+ $('p-mine').title = shown.length ? `HeartCode uses ${shown[0].provider} · ${shown[0].model}. Open the Your models page: the order, tests, and what happens when one fails` : ''
1234
1346
  fillHeight()
1235
1347
 
1236
1348
  // every provider's models, A to Z; one click adds it (the first one becomes #1). Built
@@ -1360,9 +1472,22 @@ const addModel = (provider, id) => {
1360
1472
  const order = S.order
1361
1473
  return order.length ? saveOrder([...order, { provider, model: id }], `${id} added as #${order.length + 1}`) : useModel(provider, id)
1362
1474
  }
1475
+ // a provider switched off: its models stay in your list, but HeartCode skips them; the one in use is
1476
+ // the first of a provider that's on
1477
+ const isOff = (name) => Boolean(S?.providers.find((p) => p.name === name)?.off)
1478
+ const inUse = () => S.order.find((t) => !isOff(t.provider))
1479
+ // Your models as shown: none of a provider that's switched off (they come back when it's on again)
1480
+ const shownOrder = () => S.order.filter((t) => !isOff(t.provider))
1481
+ // An order saved from what's shown: the models of a provider that's off keep their places in yours
1482
+ const withOff = (shown) => {
1483
+ const rest = shown[Symbol.iterator]()
1484
+ return [...S.order.map((t) => (isOff(t.provider) ? t : rest.next().value)).filter(Boolean), ...rest]
1485
+ }
1363
1486
  function rowTail(provider, id) {
1364
- const at = placeOf(provider, id)
1365
- return at === 0 ? h('span', { className: 'tag brand', textContent: '#1 in use' }) : at > 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
1487
+ if (placeOf(provider, id) >= 0 && isOff(provider)) return h('span', { className: 'tag', textContent: 'in your list · off', title: `${provider} is switched off: HeartCode skips its models (and Your models doesn't show them) until it's on` })
1488
+ const at = shownOrder().findIndex((t) => t.provider === provider && t.model === id) // its place among the ones HeartCode uses
1489
+ const now = inUse()
1490
+ return now?.provider === provider && now.model === id ? h('span', { className: 'tag brand', textContent: `#${at + 1} in use` }) : at >= 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
1366
1491
  }
1367
1492
  // what a listed model can do: as tested or set up, else what models.dev says (one of yours: its
1368
1493
  // profile, also when the provider's list is off)
@@ -1545,14 +1670,18 @@ function buildCatalog(s) {
1545
1670
  }
1546
1671
  if (q && !ids.length) return null // searching: only the providers with a match
1547
1672
  if (freeOnly && !ids.length) return null // free only: the providers with a free one
1548
- return h('section', { 'data-provider': p.name },
1673
+ return h('section', { 'data-provider': p.name, className: p.off ? 'off' : '' },
1549
1674
  // the header folds it (a click on it, or its arrow; not on its buttons and switch)
1550
1675
  h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
1551
1676
  h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
1552
1677
  ids.length ? h('input', { type: 'checkbox', className: 'pick-all', 'data-provider': p.name, 'aria-label': `select ${p.name}'s models`, title: `select all of ${p.name}'s models shown (or none)`, onchange: (e) => (ids.forEach((id) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id))), drawPicks()) }) : null,
1678
+ h('label', { className: 'sw', title: p.off ? `switched off: HeartCode skips ${p.name}'s models. Switch it on` : `switch ${p.name} off: HeartCode skips its models (nothing is deleted)` },
1679
+ h('input', { type: 'checkbox', checked: !p.off, 'aria-label': `${p.name} on`, onchange: (e) => act('/api/provider/on', { name: p.name, on: e.target.checked }, e.target.checked ? `${p.name} is on` : `${p.name} is off: HeartCode skips its models`) }),
1680
+ h('i')),
1553
1681
  h('div', { className: 'who' },
1554
1682
  h('span', { className: 'name', textContent: p.name }),
1555
- m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: add a model by its name, in the box on the right" }) : m.loading ? h('span', { className: 'tag', textContent: 'loading…' }) : m.error ? h('span', { className: 'tag err', textContent: "can't list its models", title: m.error }) : h('span', { className: 'tag ok', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
1683
+ p.off ? h('span', { className: 'tag', textContent: 'off' }) : null,
1684
+ m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: add a model by its name, in the box on the right" }) : m.loading ? h('span', { className: 'tag', textContent: 'loading…' }) : m.error ? h('span', { className: 'tag err', textContent: "can't list its models", title: m.error }) : h('span', { className: 'tag', textContent: `${m.list.length} model${m.list.length === 1 ? '' : 's'}` }),
1556
1685
  h('span', { className: 'tag inlist' }),
1557
1686
  h('span', { className: 'meta mono', textContent: p.baseUrl, title: `${p.baseUrl} · key ${p.key || 'none'}` })),
1558
1687
  h('form', { className: 'by-name', onsubmit: addByName },
@@ -1594,7 +1723,7 @@ let SORT = []
1594
1723
  // your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
1595
1724
  let RANK = []
1596
1725
  const ranked = () => {
1597
- const names = [...new Set(S.order.map((t) => t.provider))]
1726
+ const names = [...new Set(shownOrder().map((t) => t.provider))]
1598
1727
  return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
1599
1728
  }
1600
1729
  // the ranking dialog: drag a provider to its place, then Save
@@ -1654,7 +1783,7 @@ const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your rankin
1654
1783
  const PICKED = new Set()
1655
1784
  let lastPicked = null
1656
1785
  function drawMine(s) {
1657
- const order = s.order
1786
+ const order = shownOrder() // a provider switched off: its models aren't shown (nor tested, sorted, ranked)
1658
1787
  $('n-mine').textContent = order.length || ''
1659
1788
  const view = sorted(order)
1660
1789
  for (const k of PICKED) if (placeOf(...k.split(/\/(.*)/s)) === undefined) PICKED.delete(k) // gone from the list
@@ -1663,7 +1792,7 @@ function drawMine(s) {
1663
1792
  const n = order.length - rest.length
1664
1793
  if (!confirm(`Take ${n} model${n === 1 ? '' : 's'} off your list?${!rest.length ? ' None is left then: HeartCode has no model to use until you choose one.' : rest[0] !== order[0] ? ` HeartCode then uses ${rest[0].provider} · ${rest[0].model}.` : ''} (They stay under their providers.)`)) return
1665
1794
  PICKED.clear()
1666
- saveOrder(rest, `${n} removed${rest.length ? '' : ': no model chosen now'}`)
1795
+ saveOrder(withOff(rest), `${n} removed${rest.length ? '' : ': no model chosen now'}`)
1667
1796
  }
1668
1797
  const all = view.length > 0 && view.every((t) => PICKED.has(keyOf(t)))
1669
1798
  // the tests' results, by outcome: a click selects those models (again: unselects them)
@@ -1681,7 +1810,7 @@ function drawMine(s) {
1681
1810
  const keep = (list, moved) => {
1682
1811
  SORT = []
1683
1812
  savePref('sort', SORT)
1684
- return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
1813
+ return saveOrder(withOff(list), list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
1685
1814
  }
1686
1815
  // a row dragged (in the order shown, sorted or not)
1687
1816
  const move = (i, j) => {
@@ -1706,13 +1835,13 @@ function drawMine(s) {
1706
1835
  lastPicked = i
1707
1836
  drawMine(S)
1708
1837
  }
1709
- const tableV = table && virtual(table, { count: view.length, height: TABLE_ROW, row: (i) => tableRow(view[i], placeOf(view[i].provider, view[i].model), (e) => pick(i, e)), scroller: window })
1838
+ const tableV = table && virtual(table, { count: view.length, height: TABLE_ROW, row: (i) => tableRow(view[i], order.indexOf(view[i]), (e) => pick(i, e)), scroller: window })
1710
1839
  if (tableV) tableV.items = view.length
1711
1840
  put($('mt'),
1712
1841
  h('div', { className: 'mt-top' },
1713
1842
  h('div', { className: 'sorts' }, SORT.length
1714
1843
  ? ['Sorted by ', SORT.map(({ key, dir }, k) => h('span', { className: 'chip' }, `${k + 1}. ${COLS[key].label}: ${dirText(key, dir)}`, h('button', { type: 'button', textContent: '✕', 'aria-label': `stop sorting by ${COLS[key].label}`, onclick: () => setSort(SORT.filter((x) => x.key !== key)) })))]
1715
- : order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
1844
+ : order.length > 1 ? 'Your order: #1 first, the next when one fails.' : ''),
1716
1845
  PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
1717
1846
  PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
1718
1847
  PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
@@ -1721,17 +1850,17 @@ function drawMine(s) {
1721
1850
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1722
1851
  order.length > 1 ? h('button', { className: 'small primary', textContent: 'Save order', title: 'save the order shown as your model order (#1 first)', onclick: () => keep(view) }) : null,
1723
1852
  order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
1724
- order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
1853
+ order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll(order) }) : null),
1725
1854
  // when the one in use fails, and what a new message starts with
1726
1855
  order.length > 1 ? h('div', { className: 'mt-rules' },
1727
1856
  h('span', { className: 'rule' },
1728
1857
  h('label', { for: 'r-on', textContent: 'When a model fails, go on to the next' }),
1729
- h('select', { id: 'r-on', onchange: (e) => saveOrder(order, 'saved', { on: e.target.value }) },
1858
+ h('select', { id: 'r-on', onchange: (e) => saveOrder(S.order, 'saved', { on: e.target.value }) },
1730
1859
  [['failure', 'at its first failure'], ['retries', 'after some tries of the same model']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
1731
- s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(order, 'saved', { retries: Number(e.target.value) }) }) : null),
1860
+ s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first (rate limits, outages)', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => saveOrder(S.order, 'saved', { retries: Number(e.target.value) }) }) : null),
1732
1861
  h('span', { className: 'rule' },
1733
1862
  h('label', { for: 'r-next', textContent: 'A new message starts with' }),
1734
- h('select', { id: 'r-next', onchange: (e) => saveOrder(order, 'saved', { next: e.target.value }) },
1863
+ h('select', { id: 'r-next', onchange: (e) => saveOrder(S.order, 'saved', { next: e.target.value }) },
1735
1864
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))),
1736
1865
  h('span', { className: 'hint', textContent: s.rotation.on === 'retries' ? 'A rate limit or outage: that model is tried again that many times first; any other error moves on at once.' : "Any error moves on at once, to another provider's model first." })) : null,
1737
1866
  order.length
@@ -1759,12 +1888,12 @@ function tableRow(t, at, pick) {
1759
1888
  const c = can(t)
1760
1889
  const ctx = COLS.context.get(t)
1761
1890
  const picked = PICKED.has(keyOf(t))
1762
- return h('li', { className: 'in' + (picked ? ' picked' : ''), 'data-key': `${t.provider}/${t.model}` },
1891
+ return h('li', { className: 'in' + (picked ? ' picked' : '') + (isOff(t.provider) ? ' off' : ''), 'data-key': `${t.provider}/${t.model}`, title: isOff(t.provider) ? `${t.provider} is switched off: HeartCode skips this model until it's on` : '' },
1763
1892
  h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
1764
1893
  h('input', { type: 'checkbox', checked: picked, 'aria-label': `select ${t.model}`, title: 'select (Shift: and every row up to the last one selected)', onclick: (e) => (e.preventDefault(), pick(e)) }),
1765
1894
  h('span', { className: 'n', textContent: `#${at + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
1766
1895
  h('span', { className: 'm', textContent: t.model, title: t.model }),
1767
- h('span', { className: 'p', textContent: t.provider, title: t.provider }),
1896
+ h('span', { className: 'p', textContent: isOff(t.provider) ? `${t.provider} · off` : t.provider, title: t.provider }),
1768
1897
  h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
1769
1898
  h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
1770
1899
  yes(c.tools), yes(c.thinking), yes(c.images),
@@ -1786,15 +1915,15 @@ function markCatalog() {
1786
1915
  const n = S.order.filter((t) => t.provider === sec.dataset.provider).length
1787
1916
  const tag = sec.querySelector('.inlist')
1788
1917
  tag.hidden = !n
1789
- tag.textContent = `${n} in your list${S.order[0]?.provider === sec.dataset.provider ? ' · #1' : ''}`
1918
+ tag.textContent = `${n} in your list${inUse()?.provider === sec.dataset.provider ? ' · in use' : ''}`
1790
1919
  }
1791
1920
  drawPicks()
1792
1921
  }
1793
1922
 
1794
1923
  function drawUsing(s) {
1795
- const o = s.order
1924
+ const o = s.order.filter((t) => !isOff(t.provider))
1796
1925
  $('using').hidden = page() === 'providers' // there it's on the page itself
1797
- put($('using'), o.length ? [h('span', { className: 'k', textContent: 'HeartCode uses' }), h('b', { textContent: o[0].model }), h('span', { className: 'k', textContent: `${o[0].provider}${o.length > 1 ? ` · ${o.length - 1} fallback${o.length > 2 ? 's' : ''}` : ''}` })] : [h('span', { className: 'k', textContent: 'No model chosen yet' }), h('a', { href: '#providers', textContent: 'Choose one' })])
1926
+ put($('using'), o.length ? [h('span', { className: 'k', textContent: 'HeartCode uses' }), h('b', { textContent: o[0].model }), h('span', { className: 'k', textContent: `${o[0].provider}${o.length > 1 ? ` · ${o.length - 1} fallback${o.length > 2 ? 's' : ''}` : ''}` })] : s.order.length ? [h('span', { className: 'k', textContent: 'The providers of all your models are switched off' }), h('a', { href: '#providers', textContent: 'Switch one on' })] : [h('span', { className: 'k', textContent: 'No model chosen yet' }), h('a', { href: '#providers', textContent: 'Choose one' })])
1798
1927
  }
1799
1928
 
1800
1929
  // --- General ---
@@ -2128,6 +2257,8 @@ else {
2128
2257
  // A model's test, as it goes: the server streams each change; only its line in your list changes.
2129
2258
  if (token) {
2130
2259
  const events = new EventSource(`/api/events?t=${token}`)
2260
+ events.addEventListener('error', checkGone) // the server went away (turned off), or the connection did for a moment
2261
+ events.addEventListener('open', () => $('gone').open && checkGone())
2131
2262
  // a request logged: at the top of the list, nothing else redrawn
2132
2263
  // the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
2133
2264
  // when it closes (what's typed in it stays)