heartcode 0.1.14 → 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,14 +201,15 @@
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; }
208
+ .cat-top .free-only { display: flex; align-items: center; gap: 7px; flex: none; font-size: 13px; color: var(--muted); cursor: pointer; white-space: nowrap; }
158
209
  .cat-top button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
159
- .cat-top input[type=checkbox], .prov-h .pick-all, .mwrap .pick { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
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; }
160
211
  /* what to do with the ticked models: at the bottom of the list, only while some are ticked */
161
- .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; }
162
213
  .picks .n { font-weight: 600; margin-right: auto; }
163
214
  .prov-h { cursor: pointer; }
164
215
  .prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; }
@@ -166,25 +217,52 @@
166
217
  section.collapsed .prov-h .fold { transform: rotate(-90deg); }
167
218
  section.collapsed > :not(.prov-h) { display: none; } /* folded: just its header */
168
219
  .cat-list { flex: 1; overflow-y: auto; }
169
- .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)); }
170
222
  .prov-h .who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
171
- .prov-h .name { font-weight: 700; font-size: 15px; }
172
- .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; }
173
225
  .prov-h .meta .mono { font-size: 12px; }
174
226
  .rows { display: flex; flex-direction: column; } /* one model per line, A to Z */
175
- .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; }
176
- .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; }
177
229
  .mrow.flash { animation: flash 1.6s ease-out; }
178
230
  @keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
179
- .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; }
180
232
  .rows > .mrow, .rows > .mwrap { height: 56px; } /* one height: a long list only draws what's in view */
181
233
  .mwrap .mrow { height: 100%; }
182
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; }
183
- .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; }
184
237
  .mrow.taken .id { color: var(--muted); }
185
- .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); }
186
240
  .mwrap .mrow { border-bottom: 0; }
187
241
  .mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 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); }
245
+ .prov-h .by-name { display: flex; gap: 6px; flex: none; }
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; }
250
+ .prov-h .by-name button, .prov-h > button.small { height: 28px; }
251
+ .hello-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
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; }
253
+ #hello-status.ok { color: var(--ok); }
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); }
258
+ @media (max-width: 860px) { .hello-cols { grid-template-columns: 1fr; } }
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; }
261
+ #hellos-list button:last-child { border-bottom: 0; }
262
+ #hellos-list .id { flex: none; width: 40%; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
263
+ #hellos-list .said { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
264
+ #hellos-list .ok .said { color: var(--ok); }
265
+ #hellos-list .bad .said { color: var(--err); }
188
266
  .mwrap { padding-left: 16px; align-items: center; }
189
267
  .mwrap .pick + .mrow { padding-left: 12px; }
190
268
  .mwrap .forget:hover { color: var(--err); }
@@ -199,10 +277,10 @@
199
277
  .reqs { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: 420px; }
200
278
  .req-list { max-height: 70vh; overflow-y: auto; border-right: 1px solid var(--line); }
201
279
  .req-detail { max-height: 70vh; overflow-y: auto; padding: 16px 18px; min-width: 0; }
202
- .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; }
203
- .rq:hover { background: var(--hover); }
204
- .rq.on { background: var(--accent-soft); border-left-color: var(--accent); }
205
- .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); }
206
284
  .rq.failed.on { background: var(--err-soft); }
207
285
  .rq .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
208
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; }
@@ -211,7 +289,7 @@
211
289
  .kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 8px 0 14px; }
212
290
  .kv dt { color: var(--muted); }
213
291
  .kv dd { margin: 0; overflow-wrap: anywhere; }
214
- .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; }
215
293
  .req-detail details { margin: 4px 0; }
216
294
  .req-detail summary { cursor: pointer; font-size: 13px; color: var(--muted); }
217
295
  .req-detail h3 { font-size: 14px; margin: 16px 0 4px; }
@@ -224,15 +302,18 @@
224
302
  .olist .test button { flex: none; }
225
303
  .olist .test.ok { color: var(--ok); }
226
304
  .olist .test.warn { color: var(--accent); }
227
- 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; }
228
- dialog::backdrop { background: rgba(0, 0, 0, .55); }
229
- 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; }
230
310
  dialog#ms .opt:last-of-type { border-bottom: 0; }
231
311
  #pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
232
- #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); }
233
314
  #pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
234
315
  #pr-list li.dragging { opacity: .4; }
235
- 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); }
236
317
  .olist .test.failed { color: var(--err); }
237
318
  .olist .test.testing, .olist .test.waiting { color: var(--accent); }
238
319
  .olist .test button { padding: 0 7px; font-size: 11px; }
@@ -257,9 +338,9 @@
257
338
  body.sorting, body.sorting * { cursor: grabbing !important; }
258
339
  /* Your models: a table, one row a model */
259
340
  .mt-card { padding: 0; }
260
- .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; }
261
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 */
262
- .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; }
263
344
  .mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
264
345
  .mt-scroll { overflow-x: auto; }
265
346
  .mt { min-width: 1116px; }
@@ -273,7 +354,7 @@
273
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); }
274
355
  .mt-outcomes > span:first-child { margin-right: 4px; }
275
356
  /* a test result: what it was and how many (a click selects them), and ↻ to test them again */
276
- .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; }
277
358
  .pill button { border: 0; border-radius: 0; background: none; padding: 5px 8px; display: inline-flex; align-items: center; gap: 5px; }
278
359
  .pill button:hover:not(:disabled) { background: var(--hover); }
279
360
  .pill .what { font-weight: 600; }
@@ -282,30 +363,36 @@
282
363
  .pill .n { color: var(--muted); font-variant-numeric: tabular-nums; }
283
364
  .pill .again { border-left: 1px solid var(--line); padding: 5px 6px; color: var(--muted); }
284
365
  .pill .again:hover:not(:disabled) { color: var(--text); }
285
- .pill.on { border-color: var(--brand); background: var(--brand-soft); }
286
- .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); }
287
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; }
288
- .mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
289
- .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; }
290
373
  .mt-h button:hover { color: var(--text); }
291
- .mt-h button.on { color: var(--brand); font-weight: 700; }
374
+ .mt-h button.on { color: var(--accent); font-weight: 650; }
292
375
  .mt-h .num, .mtable .num { text-align: right; }
293
376
  .mtable { list-style: none; margin: 0; padding: 0; }
294
- .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); }
295
379
  .mtable li:last-child { border-bottom: 0; }
296
380
  .mtable li.in { user-select: none; transition: transform .16s ease, box-shadow .16s ease; }
297
381
  .mtable.drag li.in { cursor: grab; }
298
- .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); }
299
383
  .mtable .grip { color: var(--faint); touch-action: none; }
300
384
  .mtable:not(.drag) .grip { visibility: hidden; }
301
- .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); }
302
387
  .mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
303
388
  .mtable .p { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
304
389
  .mtable .yes { color: var(--ok); }
305
390
  .mtable .no { color: var(--faint); }
306
391
  .mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
307
392
  .mtable .src.warn { color: var(--accent); }
308
- .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); }
309
396
  .mtable .try { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
310
397
  .mtable .try .msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
311
398
  .mtable .try.ok { color: var(--ok); }
@@ -330,28 +417,28 @@
330
417
  .opt:first-child { padding-top: 0; }
331
418
  .opt .t { flex: 1; min-width: 0; }
332
419
  .opt b { display: block; }
333
- .opt span { color: var(--muted); font-size: 13px; }
420
+ .opt span { color: var(--muted); font-size: 13px; line-height: 1.45; }
334
421
  .sw { position: relative; width: 38px; height: 22px; flex: none; margin-top: 2px; }
335
422
  .sw input { opacity: 0; width: 0; height: 0; position: absolute; }
336
- .sw i { position: absolute; inset: 0; border-radius: 11px; background: var(--line); transition: background .15s; }
337
- .sw i::after { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: transform .15s; }
338
- .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); }
339
426
  .sw input:checked + i::after { transform: translateX(16px); }
340
427
  .sw input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; }
341
428
 
342
429
  /* choices as cards (permissions, colors) */
343
430
  .choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
344
- .choice { position: relative; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; cursor: pointer; background: var(--panel); }
345
- .choice:hover { border-color: var(--accent); }
346
- .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); }
347
434
  .choice b { display: block; }
348
- .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; }
349
436
  .choice input { position: absolute; opacity: 0; }
350
437
  .swatch { display: flex; gap: 3px; margin-bottom: 8px; }
351
438
  .swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid var(--line); }
352
439
 
353
440
  /* a model's tooltip: shown at once on hover (or keyboard focus), next to the row */
354
- #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; }
355
442
  #tip.show { opacity: 1; }
356
443
  #tip .tid { font-family: var(--mono); font-weight: 700; font-size: 13px; word-break: break-all; }
357
444
  #tip .tname { color: var(--muted); margin-top: 1px; }
@@ -360,14 +447,15 @@
360
447
  #tip dd { margin: 0; }
361
448
  #tip .tdesc { color: var(--muted); margin-top: 10px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
362
449
  #tip .thint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); color: var(--accent); font-weight: 600; }
363
- #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; }
364
- #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); }
365
452
  #toast.err { background: var(--err); color: #fff; }
366
453
  /* what's running (tests, loading): a strip at the top of the panel it's about, updated in place */
367
454
  .job-slot:empty { display: none; }
368
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 */
369
456
  .job-slot.solo .job:last-child { border-bottom: 0; }
370
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); }
371
459
  .job .top { display: flex; align-items: center; gap: 10px; min-height: 24px; }
372
460
  .job .spin { flex: none; color: var(--brand); }
373
461
  .job .what { font-weight: 600; white-space: nowrap; }
@@ -395,7 +483,9 @@
395
483
  .brand { padding: 0 0 8px; }
396
484
  nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }
397
485
  nav a { flex: none; margin: 0; }
486
+ nav a.on::before { display: none; } /* a strip of tabs: the highlighted one is enough */
398
487
  nav a .count, .using { display: none; }
488
+ .theme-pick { margin: 6px 0 4px; max-width: 280px; }
399
489
  main { padding: 20px 16px 60px; }
400
490
  .steps { grid-template-columns: 1fr; }
401
491
  .choices { grid-template-columns: 1fr; }
@@ -412,7 +502,7 @@
412
502
  <body>
413
503
  <div class="app">
414
504
  <aside>
415
- <div class="brand"><span>♥</span> HeartCode settings</div>
505
+ <div class="brand"><span>♥</span> HeartCode <small>settings</small></div>
416
506
  <nav id="nav">
417
507
  <a href="#providers" data-page="providers"><span class="ic">◉</span> Providers &amp; models <span class="count" id="n-providers"></span></a>
418
508
  <a href="#models" data-page="models"><span class="ic">☰</span> Your models <span class="count" id="n-mine"></span></a>
@@ -424,6 +514,9 @@
424
514
  <a href="#files" data-page="files"><span class="ic">≡</span> Advanced files</a>
425
515
  </nav>
426
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>
427
520
  </aside>
428
521
 
429
522
  <main>
@@ -431,7 +524,7 @@
431
524
  <!-- Providers & models -->
432
525
  <section class="page" id="page-providers">
433
526
  <div class="head">
434
- <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>
435
528
  <div class="head-actions"><button id="p-mine" type="button"></button><button class="primary" id="p-open">+ Add a provider</button></div>
436
529
  </div>
437
530
  <div class="steps" id="steps"></div>
@@ -440,7 +533,8 @@
440
533
  <div class="cat-top">
441
534
  <input type="checkbox" id="pick-shown" aria-label="select every model shown">
442
535
  <input type="search" id="m-search" placeholder="Search all models…">
443
- <select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price</option></select>
536
+ <label class="free-only" title="only the models their provider lists at $0 (a provider whose list has no prices can't say)"><input type="checkbox" id="free-only">Free only</label>
537
+ <select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price (free first)</option></select>
444
538
  <button class="small ghost" id="fold-all" type="button">Collapse all</button>
445
539
  <button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button>
446
540
  </div>
@@ -453,7 +547,7 @@
453
547
 
454
548
  <!-- Your models: all at once, sortable -->
455
549
  <section class="page" id="page-models">
456
- <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>
457
551
  <div class="card mt-card" id="mt"></div>
458
552
  </section>
459
553
 
@@ -535,7 +629,7 @@
535
629
  </div>
536
630
  <div class="card">
537
631
  <h2>Voice input</h2>
538
- <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>
539
633
  <textarea id="v-text" spellcheck="false" rows="3" placeholder="(whisper-cli on this machine)"></textarea>
540
634
  <div class="actions"><button class="primary" id="v-save">Save voice</button></div>
541
635
  </div>
@@ -606,7 +700,7 @@
606
700
  <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
607
701
  <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
608
702
  <div id="p-more" hidden>
609
- <label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (type one's exact name in the search).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
703
+ <label class="opt" style="margin-top:16px"><div class="t"><b>Load its model list</b><span>On: its models are asked from the provider. Off: never asked; only the models you add by name show (the box next to its name).</span></div><span class="sw"><input type="checkbox" id="p-list-on"><i></i></span></label>
610
704
  <div class="actions" id="p-added"></div>
611
705
  </div>
612
706
  <div class="actions"><button class="danger" id="p-delete" hidden>Delete provider</button><span class="spacer"></span><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
@@ -624,6 +718,27 @@
624
718
  <div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
625
719
  </dialog>
626
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>
727
+ <dialog id="hello" aria-labelledby="hello-title">
728
+ <h2 id="hello-title"></h2>
729
+ <p class="sub" id="hello-said"></p>
730
+ <div class="hello-cols">
731
+ <div><h3>Sent</h3><pre id="hello-sent"></pre></div>
732
+ <div><h3>Received <span id="hello-status"></span></h3><pre id="hello-got"></pre></div>
733
+ </div>
734
+ <div class="actions"><button id="hello-again" type="button">Send again</button><span class="spacer"></span><button class="primary" id="hello-close" type="button">Close</button></div>
735
+ </dialog>
736
+ <dialog id="hellos" aria-labelledby="hellos-title">
737
+ <h2 id="hellos-title"></h2>
738
+ <p class="sub" id="hellos-said"></p>
739
+ <div id="hellos-list"></div>
740
+ <div class="actions"><button id="hellos-ok" type="button">Tick the ones that answered</button><span class="spacer"></span><button class="primary" id="hellos-close" type="button">Close</button></div>
741
+ </dialog>
627
742
  <dialog id="pr" aria-labelledby="pr-title">
628
743
  <form>
629
744
  <h2 id="pr-title">Provider ranking</h2>
@@ -636,6 +751,20 @@
636
751
  <div id="tip" role="tooltip"></div>
637
752
  <script>
638
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
+ }
639
768
  const $ = (id) => document.getElementById(id)
640
769
  const h = (tag, props = {}, ...kids) => {
641
770
  const el = document.createElement(tag)
@@ -691,8 +820,22 @@ function fillSlot(slot) {
691
820
  }
692
821
  return slot
693
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
694
834
  async function api(path, body) {
695
- 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
+ })
696
839
  const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
697
840
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
698
841
  return data
@@ -702,6 +845,7 @@ const DOING = {
702
845
  '/api/provider': 'Checking the address and saving the provider',
703
846
  '/api/provider/delete': 'Deleting the provider',
704
847
  '/api/provider/list': 'Saving the model list setting',
848
+ '/api/provider/on': 'Switching the provider',
705
849
  '/api/order': 'Saving your models',
706
850
  '/api/models-dev': null, // its own strip
707
851
  }
@@ -951,6 +1095,14 @@ let modelQuery = ''
951
1095
  // storage isn't: the page's address has a new port each time)
952
1096
  const savePref = (key, value) => api('/api/prefs', { key, value }).catch((e) => toast(`not kept: ${e.message}`, true))
953
1097
  let catSort = 'name'
1098
+ // only the models their provider lists at $0 (what its own list says: the price shown under each)
1099
+ let freeOnly = false
1100
+ const isFree = (d) => Boolean(d?.price) && !d.price.input && !d.price.output
1101
+ $('free-only').onchange = (e) => {
1102
+ freeOnly = e.target.checked
1103
+ savePref('freeOnly', freeOnly)
1104
+ if (S) drawProviders(S)
1105
+ }
954
1106
  $('m-sort').onchange = (e) => {
955
1107
  catSort = e.target.value
956
1108
  savePref('catSort', catSort)
@@ -1187,19 +1339,22 @@ function drawProviders(s) {
1187
1339
  $('steps').hidden = order.length > 0 // the first start only
1188
1340
  $('p-work').hidden = none
1189
1341
  // your list, at the top: how many, and the way to its page (order, tests, what to do when one fails)
1190
- $('p-mine').hidden = !order.length
1191
- $('p-mine').textContent = `Your models (${order.length}) →`
1192
- $('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` : ''
1193
1346
  fillHeight()
1194
1347
 
1195
1348
  // every provider's models, A to Z; one click adds it (the first one becomes #1). Built
1196
1349
  // again only when what it lists changes (a provider, its models, the search); a change to your
1197
- // list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
1198
- const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort])
1350
+ // list only updates the rows it touches (markCatalog), so the list stays where you scrolled it
1351
+ // (but one of yours a provider doesn't list gets a row of its own).
1352
+ const unlisted = order.filter((t) => !(MODELS[t.provider]?.list ?? []).includes(t.model)).map(keyOf).sort()
1353
+ const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort, freeOnly, unlisted])
1199
1354
  if (shape !== catalogShape) (buildCatalog(s), applyFolds())
1200
1355
  catalogShape = shape
1201
1356
  markCatalog()
1202
- $('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models, or type a model name…`
1357
+ $('m-search').placeholder = `Search ${Object.values(MODELS).reduce((n, m) => n + (m.list?.length ?? 0), 0) || 'all'} models…`
1203
1358
  $('n-providers').textContent = s.providers.length || ''
1204
1359
  }
1205
1360
  // Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
@@ -1317,9 +1472,22 @@ const addModel = (provider, id) => {
1317
1472
  const order = S.order
1318
1473
  return order.length ? saveOrder([...order, { provider, model: id }], `${id} added as #${order.length + 1}`) : useModel(provider, id)
1319
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
+ }
1320
1486
  function rowTail(provider, id) {
1321
- const at = placeOf(provider, id)
1322
- 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' })
1323
1491
  }
1324
1492
  // what a listed model can do: as tested or set up, else what models.dev says (one of yours: its
1325
1493
  // profile, also when the provider's list is off)
@@ -1333,6 +1501,80 @@ function canCell(d, mine) {
1333
1501
  const text = [can, priceText(price)].filter(Boolean).join(' · ')
1334
1502
  return text ? h('span', { className: 'can', textContent: text }) : null
1335
1503
  }
1504
+ // Test on a model's row: "Hello" sent to it (by the server), and the request and the answer shown.
1505
+ // r: an answer already had (from Test on the ticked ones): shown, not sent again.
1506
+ let helloOf = null
1507
+ async function hello(provider, model, had) {
1508
+ helloOf = { provider, model }
1509
+ const show = (sent, got, status, cls) => {
1510
+ $('hello-sent').textContent = sent
1511
+ $('hello-got').textContent = got
1512
+ $('hello-status').textContent = status
1513
+ $('hello-status').className = cls
1514
+ }
1515
+ $('hello-title').textContent = `${provider} · ${model}`
1516
+ $('hello-said').textContent = 'Sending "Hello"…'
1517
+ show('', '', '', '')
1518
+ $('hello-again').disabled = true
1519
+ if (!$('hello').open) $('hello').showModal()
1520
+ const asked = helloOf
1521
+ const r = had ?? (await sendHello(provider, model))
1522
+ if (asked !== helloOf) return // another one asked meanwhile
1523
+ $('hello-again').disabled = false
1524
+ if (r.failed) return ($('hello-said').textContent = `Not sent: ${r.failed}`)
1525
+ const json = (x) => (typeof x === 'string' ? x : JSON.stringify(x, null, 2))
1526
+ const s = r.sent
1527
+ const g = r.received
1528
+ show(`${s.method} ${s.url}\n${Object.entries(s.headers).map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${json(s.body)}`,
1529
+ g.error ? g.error : `${g.type ? `content-type: ${g.type}\n\n` : ''}${json(g.body) || '(empty)'}`,
1530
+ g.error ? '· no answer' : `· ${g.status} · ${g.ms} ms`, g.ok ? 'ok' : 'bad')
1531
+ $('hello-said').textContent = helloSaid(r, 200)
1532
+ }
1533
+ const sendHello = (provider, model) => api('/api/hello', { provider, model }).catch((e) => ({ failed: e.message }))
1534
+ const helloSaid = (r, n) => {
1535
+ const g = r.received
1536
+ return r.failed ? `Not sent: ${r.failed}` : g.error ? `It didn't answer: ${g.error}` : !g.ok ? `The provider refused it (${g.status}).` : r.reply ? `It answered: ${r.reply.length > n ? r.reply.slice(0, n) + '…' : r.reply}` : 'It answered, but with no text in choices[0].message.content.'
1537
+ }
1538
+ const answered = (r) => Boolean(r?.received?.ok && r.reply)
1539
+ // Test on the ticked models: "Hello" to each, a provider's one after another (its rate limit), the
1540
+ // providers at once. Each row: what came back; clicked, what was sent and received. Closing the
1541
+ // dialog stops the ones not sent yet.
1542
+ let hellos = null
1543
+ async function helloAll(list) {
1544
+ const run = (hellos = { list: list.map((t) => ({ ...t, r: null })) })
1545
+ $('hellos-title').textContent = `Hello to ${list.length} model${list.length === 1 ? '' : 's'}`
1546
+ const draw = () => {
1547
+ if (run !== hellos) return
1548
+ const done = run.list.filter((t) => t.r)
1549
+ const ok = done.filter((t) => answered(t.r)).length
1550
+ $('hellos-said').textContent = `${done.length} of ${run.list.length} done${done.length ? ` · ${ok} answered, ${done.length - ok} didn't` : ''}${done.length < run.list.length ? ' · sending…' : ''}`
1551
+ $('hellos-ok').disabled = !ok
1552
+ put($('hellos-list'), run.list.map((t) => h('button', { type: 'button', className: !t.r ? '' : answered(t.r) ? 'ok' : 'bad', title: t.r ? 'see what was sent and what came back' : 'not answered yet', disabled: !t.r, onclick: () => hello(t.provider, t.model, t.r) },
1553
+ h('span', { className: 'id', textContent: keyOf(t) }),
1554
+ h('span', { className: 'said', textContent: t.r ? helloSaid(t.r, 120) : 'waiting…' }))))
1555
+ }
1556
+ draw()
1557
+ if (!$('hellos').open) $('hellos').showModal()
1558
+ const byProvider = Map.groupBy(run.list, (t) => t.provider)
1559
+ await Promise.all([...byProvider.values()].map(async (ts) => {
1560
+ for (const t of ts) {
1561
+ if (run !== hellos) return
1562
+ t.r = await sendHello(t.provider, t.model)
1563
+ draw()
1564
+ }
1565
+ }))
1566
+ }
1567
+ $('hellos-close').onclick = () => $('hellos').close()
1568
+ $('hellos').addEventListener('close', () => (hellos = null))
1569
+ // the ticks: the ones that answered, only
1570
+ $('hellos-ok').onclick = () => {
1571
+ PICKED_CAT.clear()
1572
+ for (const t of hellos?.list ?? []) if (answered(t.r)) pickedOf(t.provider).add(t.model)
1573
+ $('hellos').close()
1574
+ drawPicks()
1575
+ }
1576
+ $('hello-again').onclick = () => helloOf && hello(helloOf.provider, helloOf.model)
1577
+ $('hello-close').onclick = () => $('hello').close()
1336
1578
  // every model added to a provider by name forgotten; the ones in your list leave it too
1337
1579
  async function clearAdded(p) {
1338
1580
  const inList = S.order.filter((t) => t.provider === p.name && p.added.includes(t.model))
@@ -1382,14 +1624,13 @@ function drawPicks() {
1382
1624
  put($('picks'),
1383
1625
  h('span', { className: 'n', textContent: `${all.length} selected` }),
1384
1626
  h('button', { className: 'small ghost', textContent: 'Clear', onclick: () => (PICKED_CAT.clear(), drawPicks()) }),
1627
+ h('button', { className: 'small', textContent: `Test ${all.length}`, title: 'send each ticked model "Hello" and see which answer', onclick: () => helloAll(all) }),
1385
1628
  toTake.length ? h('button', { className: 'small danger', textContent: `Remove ${toTake.length} from your list`, onclick: take }) : null,
1386
1629
  toAdd.length ? h('button', { className: 'small primary', textContent: `Add ${toAdd.length} to your list`, onclick: () => (PICKED_CAT.clear(), saveOrder([...S.order, ...toAdd], `${toAdd.length} added: your list has ${S.order.length + toAdd.length} models`)) }) : null)
1387
1630
  if (was !== $('picks').hidden) fillHeight() // the list gives it room
1388
1631
  }
1389
1632
  function buildCatalog(s) {
1390
1633
  const q = modelQuery.trim()
1391
- // a name no provider lists (anywhere): offered under each provider, to use it anyway
1392
- const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
1393
1634
  SHOWN.clear()
1394
1635
  put($('p-list'), s.providers.map((p) => {
1395
1636
  if (!p.list) MODELS[p.name] = { list: [], details: {}, off: true } // its list switched off: never asked for
@@ -1398,36 +1639,56 @@ function buildCatalog(s) {
1398
1639
  // and the models added by name (and any of yours it doesn't list), marked as yours
1399
1640
  const listed = new Set(m.list ?? [])
1400
1641
  const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
1401
- const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
1642
+ const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()) && (!freeOnly || isFree(m.details?.[id])))
1402
1643
  const row = (id, own) =>
1403
1644
  h('button', { className: 'mrow', 'data-provider': p.name, 'data-model': id, onclick: () => (placeOf(p.name, id) === undefined ? addModel(p.name, id) : removeMine(p.name, id)) },
1404
1645
  h('span', { className: 'id', title: id }, id, canCell(m.details?.[id], S.order[placeOf(p.name, id)])),
1646
+ isFree(m.details?.[id]) ? h('span', { className: 'tag ok', textContent: 'free', title: `${p.name}'s model list says $0` }) : null,
1405
1647
  own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
1406
1648
  rowTail(p.name, id))
1407
1649
  // its tick box (to add or take off several at once), and one you added: ✕ forgets it (it leaves your list too)
1408
1650
  const tick = (id) => h('input', { type: 'checkbox', className: 'pick', checked: pickedOf(p.name).has(id), 'data-model': id, 'aria-label': `select ${id}`, onchange: (e) => (e.target.checked ? pickedOf(p.name).add(id) : pickedOf(p.name).delete(id), drawPicks()) })
1409
- const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id), !p.added.includes(id) ? null :
1651
+ const item = (id) => h('div', { className: 'mwrap' }, tick(id), row(id),
1652
+ h('button', { className: 'small try', textContent: 'Test', title: `send ${id} "Hello" and see what was sent and what came back`, onclick: () => hello(p.name, id) }),
1653
+ !p.added.includes(id) ? null :
1410
1654
  h('button', { className: 'forget', textContent: '✕', title: 'forget this model', 'aria-label': `forget ${id}`, onclick: async () => {
1411
1655
  if (placeOf(p.name, id) !== undefined && !confirm(`Forget ${id}? It leaves your list too.`)) return
1412
1656
  if (placeOf(p.name, id) !== undefined) await saveOrder(S.order.filter((t) => t.provider !== p.name || t.model !== id), `${id} removed`)
1413
1657
  act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
1414
1658
  } }))
1415
- const typed = !anyMatch ? [row(q, true)] : []
1416
- if (typed[0]) typed[0].dataset.typed = '1' // a name no provider lists: not part of Add all
1417
- if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
1418
- return h('section', { 'data-provider': p.name },
1659
+ // a model by its name, into your list; one it has already: said, and shown
1660
+ const addByName = (e) => {
1661
+ e.preventDefault()
1662
+ const box = e.target.elements.name
1663
+ const id = box.value.trim()
1664
+ if (!id) return
1665
+ const at = placeOf(p.name, id)
1666
+ if (at !== undefined) return showRow(p.name, id, `${id} is already in your list (#${at + 1})`)
1667
+ if (listed.has(id) || yours.includes(id)) return showRow(p.name, id, `${p.name} already has ${id}: click it to add it`)
1668
+ box.value = ''
1669
+ addModel(p.name, id)
1670
+ }
1671
+ if (q && !ids.length) return null // searching: only the providers with a match
1672
+ if (freeOnly && !ids.length) return null // free only: the providers with a free one
1673
+ return h('section', { 'data-provider': p.name, className: p.off ? 'off' : '' },
1419
1674
  // the header folds it (a click on it, or its arrow; not on its buttons and switch)
1420
1675
  h('div', { className: 'prov-h', onclick: (e) => !e.target.closest('button:not(.fold), input, label, a') && fold(p.name) },
1421
1676
  h('button', { className: 'fold', type: 'button', textContent: '▾', 'aria-label': `fold ${p.name}`, 'aria-expanded': 'true' }),
1422
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')),
1423
1681
  h('div', { className: 'who' },
1424
1682
  h('span', { className: 'name', textContent: p.name }),
1425
- m.off ? h('span', { className: 'tag', textContent: 'list off', title: "its list isn't loaded: type a model's exact name in the search to add it" }) : 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'}` }),
1426
1685
  h('span', { className: 'tag inlist' }),
1427
1686
  h('span', { className: 'meta mono', textContent: p.baseUrl, title: `${p.baseUrl} · key ${p.key || 'none'}` })),
1687
+ h('form', { className: 'by-name', onsubmit: addByName },
1688
+ h('input', { type: 'text', name: 'name', placeholder: 'model name', 'aria-label': `add a model to ${p.name} by its name`, title: `a model's exact name: added to your list, also one ${p.name} doesn't list` }),
1689
+ h('button', { className: 'small', textContent: 'Add' })),
1428
1690
  h('button', { className: 'small', textContent: 'Edit', title: 'its address and key, its model list, delete it', onclick: () => openForm(p) })),
1429
1691
  // what's listed first; loading and errors under it (the models already shown stay where they are)
1430
- typed.length ? h('div', { className: 'rows' }, typed) : null,
1431
1692
  (() => {
1432
1693
  if (!ids.length) return null
1433
1694
  SHOWN.set(p.name, ids)
@@ -1436,10 +1697,11 @@ function buildCatalog(s) {
1436
1697
  box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
1437
1698
  return box
1438
1699
  })(),
1439
- m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or type a model's exact name in the search, and click it." })) : null,
1440
- m.off && !ids.length && !typed.length ? h('div', { className: 'cat-note', textContent: "Its list is off: type a model's exact name in the search, and click it to add it." }) : null,
1700
+ m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: "Or add a model by its name, in the box above." })) : null,
1701
+ m.off && !ids.length ? h('div', { className: 'cat-note', textContent: "Its list is off: add a model by its name, in the box above." }) : null,
1441
1702
  m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
1442
1703
  }))
1704
+ if (freeOnly && !$('p-list').querySelector('section')) put($('p-list'), h('div', { className: 'cat-note', textContent: "None of your providers lists a model at $0. (A provider whose model list has no prices can't say what's free: Free only leaves those out.)" }))
1443
1705
  }
1444
1706
  // --- Your models page: the list as a table; sorted by several columns at once (each a tie-break
1445
1707
  // for the ones before it), the sort kept in this browser ---
@@ -1461,7 +1723,7 @@ let SORT = []
1461
1723
  // your ranking of the providers, best first (kept: savePref); a provider not ranked yet comes after, A to Z
1462
1724
  let RANK = []
1463
1725
  const ranked = () => {
1464
- const names = [...new Set(S.order.map((t) => t.provider))]
1726
+ const names = [...new Set(shownOrder().map((t) => t.provider))]
1465
1727
  return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
1466
1728
  }
1467
1729
  // the ranking dialog: drag a provider to its place, then Save
@@ -1521,7 +1783,7 @@ const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your rankin
1521
1783
  const PICKED = new Set()
1522
1784
  let lastPicked = null
1523
1785
  function drawMine(s) {
1524
- const order = s.order
1786
+ const order = shownOrder() // a provider switched off: its models aren't shown (nor tested, sorted, ranked)
1525
1787
  $('n-mine').textContent = order.length || ''
1526
1788
  const view = sorted(order)
1527
1789
  for (const k of PICKED) if (placeOf(...k.split(/\/(.*)/s)) === undefined) PICKED.delete(k) // gone from the list
@@ -1530,7 +1792,7 @@ function drawMine(s) {
1530
1792
  const n = order.length - rest.length
1531
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
1532
1794
  PICKED.clear()
1533
- saveOrder(rest, `${n} removed${rest.length ? '' : ': no model chosen now'}`)
1795
+ saveOrder(withOff(rest), `${n} removed${rest.length ? '' : ': no model chosen now'}`)
1534
1796
  }
1535
1797
  const all = view.length > 0 && view.every((t) => PICKED.has(keyOf(t)))
1536
1798
  // the tests' results, by outcome: a click selects those models (again: unselects them)
@@ -1548,7 +1810,7 @@ function drawMine(s) {
1548
1810
  const keep = (list, moved) => {
1549
1811
  SORT = []
1550
1812
  savePref('sort', SORT)
1551
- 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')
1552
1814
  }
1553
1815
  // a row dragged (in the order shown, sorted or not)
1554
1816
  const move = (i, j) => {
@@ -1573,13 +1835,13 @@ function drawMine(s) {
1573
1835
  lastPicked = i
1574
1836
  drawMine(S)
1575
1837
  }
1576
- 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 })
1577
1839
  if (tableV) tableV.items = view.length
1578
1840
  put($('mt'),
1579
1841
  h('div', { className: 'mt-top' },
1580
1842
  h('div', { className: 'sorts' }, SORT.length
1581
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)) })))]
1582
- : 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.' : ''),
1583
1845
  PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
1584
1846
  PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
1585
1847
  PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
@@ -1588,17 +1850,17 @@ function drawMine(s) {
1588
1850
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1589
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,
1590
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,
1591
- 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),
1592
1854
  // when the one in use fails, and what a new message starts with
1593
1855
  order.length > 1 ? h('div', { className: 'mt-rules' },
1594
1856
  h('span', { className: 'rule' },
1595
1857
  h('label', { for: 'r-on', textContent: 'When a model fails, go on to the next' }),
1596
- 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 }) },
1597
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 }))),
1598
- 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),
1599
1861
  h('span', { className: 'rule' },
1600
1862
  h('label', { for: 'r-next', textContent: 'A new message starts with' }),
1601
- 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 }) },
1602
1864
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))),
1603
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,
1604
1866
  order.length
@@ -1626,12 +1888,12 @@ function tableRow(t, at, pick) {
1626
1888
  const c = can(t)
1627
1889
  const ctx = COLS.context.get(t)
1628
1890
  const picked = PICKED.has(keyOf(t))
1629
- 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` : '' },
1630
1892
  h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
1631
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)) }),
1632
1894
  h('span', { className: 'n', textContent: `#${at + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
1633
1895
  h('span', { className: 'm', textContent: t.model, title: t.model }),
1634
- 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 }),
1635
1897
  h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
1636
1898
  h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
1637
1899
  yes(c.tools), yes(c.thinking), yes(c.images),
@@ -1653,15 +1915,15 @@ function markCatalog() {
1653
1915
  const n = S.order.filter((t) => t.provider === sec.dataset.provider).length
1654
1916
  const tag = sec.querySelector('.inlist')
1655
1917
  tag.hidden = !n
1656
- 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' : ''}`
1657
1919
  }
1658
1920
  drawPicks()
1659
1921
  }
1660
1922
 
1661
1923
  function drawUsing(s) {
1662
- const o = s.order
1924
+ const o = s.order.filter((t) => !isOff(t.provider))
1663
1925
  $('using').hidden = page() === 'providers' // there it's on the page itself
1664
- 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' })])
1665
1927
  }
1666
1928
 
1667
1929
  // --- General ---
@@ -1958,6 +2220,8 @@ function render(s) {
1958
2220
  const p = s.prefs
1959
2221
  catSort = p.catSort === 'price' ? 'price' : 'name'
1960
2222
  $('m-sort').value = catSort
2223
+ freeOnly = p.freeOnly === true
2224
+ $('free-only').checked = freeOnly
1961
2225
  FOLDED.clear()
1962
2226
  for (const n of Array.isArray(p.folded) ? p.folded : []) FOLDED.add(n)
1963
2227
  SORT = Array.isArray(p.sort) ? p.sort.filter((x) => COLS[x?.key]) : []
@@ -1993,6 +2257,8 @@ else {
1993
2257
  // A model's test, as it goes: the server streams each change; only its line in your list changes.
1994
2258
  if (token) {
1995
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())
1996
2262
  // a request logged: at the top of the list, nothing else redrawn
1997
2263
  // the settings changed (another HeartCode or page, a hand): shown at once; while a dialog is open,
1998
2264
  // when it closes (what's typed in it stays)