@oharness/server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/i18n.js +170 -4
- package/dist/i18n.js.map +1 -1
- package/dist/page.js +961 -52
- package/dist/page.js.map +1 -1
- package/dist/server.js +206 -20
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/page.js
CHANGED
|
@@ -28,8 +28,12 @@ const STYLE = String.raw `
|
|
|
28
28
|
--field:#00000006; --field-focus:#0000000d; --track:#0000001f;
|
|
29
29
|
--kw:#7c3aed; --str:#047857; --com:#94a3b8; --num:#b45309;
|
|
30
30
|
}
|
|
31
|
+
/* The dark palette, reached two ways: the system asked for it and nothing
|
|
32
|
+
here overrode that, or somebody picked it. Written once and used by both
|
|
33
|
+
selectors — two copies of twenty colours is two places for one of them to
|
|
34
|
+
drift. */
|
|
31
35
|
@media (prefers-color-scheme:dark){
|
|
32
|
-
:root{
|
|
36
|
+
:root:not([data-theme="light"]){
|
|
33
37
|
color-scheme:dark;
|
|
34
38
|
--bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
|
|
35
39
|
--accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
|
|
@@ -39,6 +43,15 @@ const STYLE = String.raw `
|
|
|
39
43
|
--kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
|
|
40
44
|
}
|
|
41
45
|
}
|
|
46
|
+
:root[data-theme="dark"]{
|
|
47
|
+
color-scheme:dark;
|
|
48
|
+
--bg:#0e1013; --panel:#15171c; --fg:#e6e6e6; --dim:#8b93a1; --line:#ffffff14;
|
|
49
|
+
--accent:#e5e7eb; --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
|
|
50
|
+
--add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
|
|
51
|
+
--hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
|
|
52
|
+
--field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
|
|
53
|
+
--kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
|
|
54
|
+
}
|
|
42
55
|
*{box-sizing:border-box}
|
|
43
56
|
/* The hidden attribute works through the browser's own display:none, which is
|
|
44
57
|
a bare element rule any class with a display beats — so .hidden = true left
|
|
@@ -184,7 +197,8 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
184
197
|
position:fixed;z-index:60;min-width:172px;max-width:228px;padding:5px;border-radius:10px;
|
|
185
198
|
background:var(--bg);box-shadow:0 1px 2px #0000000f,0 8px 28px #00000024;
|
|
186
199
|
}
|
|
187
|
-
@media (prefers-color-scheme:dark){.row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
200
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
201
|
+
:root[data-theme="dark"] .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
|
|
188
202
|
/*
|
|
189
203
|
* Destructive is carried by the label's colour, not by a filled red bar. A
|
|
190
204
|
* solid block reads as an alert that has already happened rather than as a
|
|
@@ -196,6 +210,35 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
|
|
|
196
210
|
.row-menu .menu-note{
|
|
197
211
|
padding:4px 8px 8px;font-size:11.5px;line-height:1.45;color:var(--dim);
|
|
198
212
|
}
|
|
213
|
+
/* The account's own two facts, on a surface of their own. A fill separates
|
|
214
|
+
them from the things to click without drawing a line to do it — the menu
|
|
215
|
+
already had two rules in it and a third would have made a list of five
|
|
216
|
+
entries look like three lists. */
|
|
217
|
+
.row-menu .menu-head{
|
|
218
|
+
padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
|
|
219
|
+
}
|
|
220
|
+
.menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
|
|
221
|
+
.menu-head-sub{margin-top:2px;font-size:12px;color:var(--dim)}
|
|
222
|
+
/* One entry carrying the accent, for the one that is an offer rather than a
|
|
223
|
+
destination. var(--field) was too close to the menu's own surface to read as
|
|
224
|
+
anything in the light theme — a highlight nobody can see is worse than none,
|
|
225
|
+
because it costs the layout and returns nothing. */
|
|
226
|
+
.menu-item.accent{background:var(--accent);color:var(--bg)}
|
|
227
|
+
.menu-item.accent:hover{background:var(--accent);opacity:.88}
|
|
228
|
+
/* The theme row: a label and three targets, laid out like a settings line
|
|
229
|
+
rather than a command. */
|
|
230
|
+
.menu-theme{
|
|
231
|
+
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
232
|
+
padding:4px 8px;min-height:32px;
|
|
233
|
+
}
|
|
234
|
+
.theme-group{display:flex;gap:2px;padding:2px;border-radius:8px;background:var(--field)}
|
|
235
|
+
.theme-btn{
|
|
236
|
+
display:grid;place-items:center;width:26px;height:22px;border:0;border-radius:6px;
|
|
237
|
+
background:transparent;color:var(--dim);cursor:pointer;padding:0;
|
|
238
|
+
}
|
|
239
|
+
.theme-btn:hover{color:var(--fg)}
|
|
240
|
+
.theme-btn[aria-pressed="true"]{background:var(--bg);color:var(--fg)}
|
|
241
|
+
.theme-btn .ico-sm{width:14px;height:14px}
|
|
199
242
|
/* The letter that does the same thing without the mouse. Dim, right-aligned,
|
|
200
243
|
and only ever one character — a menu whose right edge is a column of chords
|
|
201
244
|
is a keyboard reference, not a menu. */
|
|
@@ -284,7 +327,8 @@ main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
|
284
327
|
.menu-footer{padding:5px 3px 3px;box-shadow:0 -1px 0 var(--line)}
|
|
285
328
|
.menu-item.cursor{background:var(--hover)}
|
|
286
329
|
.menu-empty{padding:10px 8px;font-size:12.5px;color:var(--dim)}
|
|
287
|
-
@media (prefers-color-scheme:dark){.menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
330
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
|
|
331
|
+
:root[data-theme="dark"] .menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
|
|
288
332
|
.menu-item{
|
|
289
333
|
display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:7px 8px;border:0;
|
|
290
334
|
border-radius:7px;background:transparent;color:var(--fg);font:inherit;font-size:13px;
|
|
@@ -601,7 +645,40 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
601
645
|
*/
|
|
602
646
|
.menu.pop{position:fixed;bottom:auto;left:auto;min-width:220px}
|
|
603
647
|
#log{padding:28px 24px 8px}
|
|
604
|
-
|
|
648
|
+
/*
|
|
649
|
+
* One column width, named once.
|
|
650
|
+
*
|
|
651
|
+
* A turn and a connection card are different things that must line up, and
|
|
652
|
+
* before this they lined up by both happening to be laid out inside the pane —
|
|
653
|
+
* which stopped being true the moment one of them got a max-width. On a wide
|
|
654
|
+
* window the card then ran the full 1600px while every message sat in a 720px
|
|
655
|
+
* column, so the one element asking for a decision was the one that did not
|
|
656
|
+
* look like it belonged to the conversation.
|
|
657
|
+
*/
|
|
658
|
+
.column{max-width:720px;margin-left:auto;margin-right:auto}
|
|
659
|
+
.turn{position:relative;margin-bottom:26px}
|
|
660
|
+
|
|
661
|
+
/*
|
|
662
|
+
* The connection card: an app's own mark, its name, what is being asked, and
|
|
663
|
+
* the two answers. It is the only place a third party's brand appears in the
|
|
664
|
+
* transcript, so it stays inside the column and keeps the site's own shapes —
|
|
665
|
+
* a fill and a radius, no outline.
|
|
666
|
+
*/
|
|
667
|
+
.connection-card{display:flex;flex-direction:column;gap:10px;padding:14px 16px}
|
|
668
|
+
.connection-head{display:flex;align-items:center;gap:10px}
|
|
669
|
+
/*
|
|
670
|
+
* Fixed box, contained image: a vendor logo is whatever aspect ratio the vendor
|
|
671
|
+
* chose, and letting it set its own height made cards jump by ten pixels
|
|
672
|
+
* depending on which app was asking.
|
|
673
|
+
*/
|
|
674
|
+
.connection-logo{width:28px;height:28px;border-radius:7px;object-fit:contain;flex:none;background:var(--bg)}
|
|
675
|
+
.connection-name{font-size:14px;font-weight:600}
|
|
676
|
+
.connection-card p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
|
677
|
+
/* The answers sit at the end of the row: this card is a question, and the two
|
|
678
|
+
* replies belong where a reader arrives last rather than under the sentence
|
|
679
|
+
* they are answering. */
|
|
680
|
+
.connection-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
681
|
+
.connection-card .err{color:var(--fg)}
|
|
605
682
|
.who{
|
|
606
683
|
font-size:11.5px;color:var(--dim);margin-bottom:6px;font-weight:500;letter-spacing:.01em;
|
|
607
684
|
}
|
|
@@ -619,7 +696,44 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
|
|
|
619
696
|
.user{margin-bottom:18px}
|
|
620
697
|
.panel{max-width:720px;margin:8px auto 0}
|
|
621
698
|
.panel h2{font-size:18px;font-weight:600;letter-spacing:-.01em;margin:0 0 14px}
|
|
622
|
-
.
|
|
699
|
+
/* Only the gap below. The horizontal margin belongs to whatever is placing the
|
|
700
|
+
* card: setting all four here quietly reset the auto margins that keep a card
|
|
701
|
+
* in the reading column, and the card sat flush left while every message beside
|
|
702
|
+
* it was centred. */
|
|
703
|
+
.row-card{background:var(--panel);border-radius:10px;padding:12px 14px;margin-bottom:8px}
|
|
704
|
+
.memory-field{margin:14px 0}
|
|
705
|
+
.memory-label{display:block;font-size:12.5px;font-weight:500;margin-bottom:2px}
|
|
706
|
+
.memory-field .muted{margin-bottom:6px}
|
|
707
|
+
/* Filled, not outlined — the same shape .art-search uses. A box drawn with a
|
|
708
|
+
* line around it is the one thing this site does not do, and three of them
|
|
709
|
+
* stacked made the editor look like a form bolted onto the page rather than
|
|
710
|
+
* part of the note. The focus ring stays: that is the one border worth having,
|
|
711
|
+
* and it appears only while the field is being used. */
|
|
712
|
+
.memory-panel input,.memory-panel textarea{background:var(--field);color:var(--fg);border:0;border-radius:8px;padding:10px 12px;font:inherit;line-height:1.5;box-sizing:border-box}
|
|
713
|
+
.memory-panel input:focus,.memory-panel textarea:focus{outline:2px solid var(--ring)}
|
|
714
|
+
.memory-panel textarea{resize:vertical;min-height:130px}
|
|
715
|
+
.memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
|
|
716
|
+
.memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
|
|
717
|
+
.memory-panel .memory-search{width:100%}
|
|
718
|
+
/* Built like a settings row: one flex line, the marker drawn rather than left
|
|
719
|
+
* to the browser, so the summary text and everything below it share one left
|
|
720
|
+
* edge. */
|
|
721
|
+
.memory-panel details.memory-note>summary{display:flex;align-items:center;gap:8px;list-style:none;padding:2px 0 6px}
|
|
722
|
+
.memory-panel details.memory-note>summary::-webkit-details-marker{display:none}
|
|
723
|
+
.memory-panel details.memory-note[open]>summary .chev{transform:rotate(90deg)}
|
|
724
|
+
.memory-note-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
725
|
+
.memory-id{flex:0 0 auto;font-size:11px;color:var(--dim);letter-spacing:.02em;user-select:all}
|
|
726
|
+
/* No card of its own — the note row is already one — and indented to the
|
|
727
|
+
* summary's text rather than the card's edge, which is what .setting-body does
|
|
728
|
+
* for the same shape. The 15px is the marker column: a chevron and the gap
|
|
729
|
+
* after it, the same distance the summary text is pushed. Left at the form's
|
|
730
|
+
* own padding the fields sat nine pixels to the right of the title they
|
|
731
|
+
* belonged to, which is the sort of gap that reads as a mistake without
|
|
732
|
+
* anyone being able to say what is wrong. */
|
|
733
|
+
.memory-editor{margin:0;padding:2px 0 4px 15px;background:transparent}
|
|
734
|
+
/* The form's own background is the page's, which on a card reads as a second
|
|
735
|
+
* card inside the first one — the same doubling the padding was making. */
|
|
736
|
+
.memory-actions{display:flex;gap:8px;margin-top:14px}
|
|
623
737
|
/*
|
|
624
738
|
* A settings row: name, value, and everything else behind the triangle.
|
|
625
739
|
*
|
|
@@ -783,16 +897,16 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
783
897
|
* is filled: everything below it is an alternative for someone who cannot use
|
|
784
898
|
* a browser here. Its own class rather than .primary, which is the composer's
|
|
785
899
|
* 30x30 icon button and would collapse this into a circle. */
|
|
900
|
+
/* No rule above it. The padding and the button's own hover surface already
|
|
901
|
+
separate the account from the list — a hairline as well is a second answer
|
|
902
|
+
to a question the whitespace had settled, and it is the only line left in a
|
|
903
|
+
sidebar that has none. */
|
|
786
904
|
#account{
|
|
787
|
-
|
|
905
|
+
display:flex;align-items:center;gap:8px;
|
|
788
906
|
flex:0 0 auto;padding:8px;
|
|
789
907
|
}
|
|
790
908
|
/* Signed out this holds one nav row and nothing else, so the padding that
|
|
791
|
-
frames an account goes away and the row sits flush with the ones above it
|
|
792
|
-
and the rule above it goes too. A hairline fences off a footer, which is
|
|
793
|
-
what this is once there is an account in it, with a name and an avatar of
|
|
794
|
-
its own; signed out there is nothing on the other side of the line, just
|
|
795
|
-
the next row in the same list, drawn as if it belonged somewhere else.
|
|
909
|
+
frames an account goes away and the row sits flush with the ones above it.
|
|
796
910
|
8px across, matching the nav list above, so the icon lands on the same
|
|
797
911
|
vertical as every other row rather than 4px inside it. */
|
|
798
912
|
/* Not 4px, which is the gap between two rows in a list. This row is the last
|
|
@@ -800,7 +914,7 @@ details.provider>summary:hover{background:var(--hover)}
|
|
|
800
914
|
another row, and at 4px the text sat on the frame. 10px below, and a row
|
|
801
915
|
taller than the nav rows above: a footer is allowed to be the roomiest row
|
|
802
916
|
in the sidebar, and this one has to hold a click aimed at from the corner. */
|
|
803
|
-
#account.out{
|
|
917
|
+
#account.out{padding:4px 8px 10px}
|
|
804
918
|
#account.out .nav-item{width:100%;height:34px}
|
|
805
919
|
/* The whole row is the control. It used to be a label with a Sign out button
|
|
806
920
|
parked beside it, which gave the rarest action in the sidebar the most
|
|
@@ -834,7 +948,19 @@ button.signin-site:hover{opacity:.88}
|
|
|
834
948
|
button.signin-site:disabled{opacity:.4;cursor:default}
|
|
835
949
|
.pw-balance{display:flex;align-items:baseline;gap:6px;margin:0}
|
|
836
950
|
.pw-balance strong{font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
837
|
-
.
|
|
951
|
+
/* The paywall's own width, and its own padding. Everything here is scoped to
|
|
952
|
+
#paywall: the same .cn-body and .dlg-actions serve the connect dialog, and
|
|
953
|
+
the last time a rule meant for one of them was written without a scope it
|
|
954
|
+
resized three screens nobody had complained about. */
|
|
955
|
+
#paywall{width:min(620px,94vw);max-width:min(620px,94vw)}
|
|
956
|
+
#paywall .cn-body{padding:16px 22px 4px;gap:14px}
|
|
957
|
+
#paywall .dlg-actions{padding:18px 22px 20px}
|
|
958
|
+
#paywall .cn-head{padding:20px 22px 2px}
|
|
959
|
+
/* Two across on a screen with room, one on a phone. Four tiers in a single
|
|
960
|
+
column made a dialog taller than the window it interrupts, which is the one
|
|
961
|
+
shape a modal should not have. */
|
|
962
|
+
.pw-plans{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
|
963
|
+
@media (max-width:560px){.pw-plans{grid-template-columns:1fr}}
|
|
838
964
|
/* Every row carries the colour. This dialog interrupts a turn that could not
|
|
839
965
|
* be paid for, so each row is the way out of it rather than one recommendation
|
|
840
966
|
* and a set of alternatives — and an outlined row beside a filled one reads as
|
|
@@ -845,14 +971,63 @@ button.signin-site:disabled{opacity:.4;cursor:default}
|
|
|
845
971
|
* .primary is the composer's 30x30 icon button, and inheriting its width,
|
|
846
972
|
* height and display:grid collapsed this row into a square with the label
|
|
847
973
|
* clipped inside it. */
|
|
974
|
+
/* Quiet cards, one mark.
|
|
975
|
+
*
|
|
976
|
+
* These used to be filled with the accent, every one of them, on the reasoning
|
|
977
|
+
* that each row is the way out of a turn that could not be paid for. That held
|
|
978
|
+
* when they were a short flat list. At four tiers plus a ladder of top-ups it
|
|
979
|
+
* is eight blocks of inverted colour in a small dialog, and the eye has
|
|
980
|
+
* nowhere to rest — the reader is choosing between shades rather than reading
|
|
981
|
+
* prices.
|
|
982
|
+
*
|
|
983
|
+
* The site's pricing page settled this and says why in its own stylesheet: the
|
|
984
|
+
* offers should read as columns of type rather than as a row of frames, and on
|
|
985
|
+
* a screen that is mostly numbers the frames are the loudest thing on it. So
|
|
986
|
+
* the fill goes quiet and the recommended rung keeps the one outline, which is
|
|
987
|
+
* what makes it a mark rather than a shade somebody has to notice.
|
|
988
|
+
*/
|
|
848
989
|
button.pw-plan{
|
|
849
|
-
display:flex;
|
|
850
|
-
min-height:
|
|
851
|
-
background:var(--
|
|
852
|
-
}
|
|
853
|
-
button.pw-plan:
|
|
990
|
+
display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
|
|
991
|
+
min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
|
|
992
|
+
background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
|
|
993
|
+
}
|
|
994
|
+
button.pw-plan.featured{border-color:var(--border-strong,var(--fg));background:var(--sel)}
|
|
995
|
+
.pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
996
|
+
button.pw-plan:hover{background:var(--sel)}
|
|
997
|
+
button.pw-plan.featured:hover{background:var(--hover)}
|
|
854
998
|
button.pw-plan:disabled{opacity:.4;cursor:default}
|
|
855
|
-
.pw-plan-
|
|
999
|
+
.pw-plan-name{display:flex;align-items:center;gap:6px;min-width:0;font-weight:500}
|
|
1000
|
+
.pw-plan-under{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
1001
|
+
.pw-plan-credits{color:var(--dim);font-variant-numeric:tabular-nums;font-size:11.5px}
|
|
1002
|
+
.pw-plan-under>span:first-child{color:var(--dim);font-variant-numeric:tabular-nums;font-size:11.5px}
|
|
1003
|
+
/* Sits under the price rather than beside the name: the badge up there says
|
|
1004
|
+
which rung to take, this one says what taking it for a year costs. */
|
|
1005
|
+
.pw-save{font-size:11px;font-weight:500;color:var(--fg);flex:0 0 auto;font-variant-numeric:tabular-nums}
|
|
1006
|
+
/* The money, hard against the right edge and in the same tabular figures as
|
|
1007
|
+
the credits, so four rows read as a column of prices rather than four
|
|
1008
|
+
sentences that happen to end in numbers. */
|
|
1009
|
+
.pw-plan-price{color:var(--fg);font-weight:500;font-variant-numeric:tabular-nums;flex:0 0 auto;white-space:nowrap}
|
|
1010
|
+
/* The one inverted thing left, and the same shape the site uses for it. */
|
|
1011
|
+
.pw-tag{
|
|
1012
|
+
font-size:10.5px;font-weight:600;letter-spacing:.02em;padding:1px 7px;border-radius:999px;
|
|
1013
|
+
background:var(--fg);color:var(--bg);flex:0 0 auto;
|
|
1014
|
+
}
|
|
1015
|
+
/* One choice about the four rows beneath, so it is a control rather than four
|
|
1016
|
+
more buttons. */
|
|
1017
|
+
.pw-toggle{display:flex;gap:4px;padding:3px;border-radius:10px;background:var(--field);margin-bottom:2px}
|
|
1018
|
+
.pw-tab{
|
|
1019
|
+
flex:1;height:28px;border:0;border-radius:8px;background:transparent;color:var(--dim);
|
|
1020
|
+
font:inherit;font-size:12.5px;cursor:pointer;
|
|
1021
|
+
}
|
|
1022
|
+
.pw-tab:hover{color:var(--fg)}
|
|
1023
|
+
.pw-tab.on{background:var(--bg);color:var(--fg);font-weight:500}
|
|
1024
|
+
/* A labelled break, not a rule: what follows is a different kind of purchase,
|
|
1025
|
+
and a bare line would read as the list simply continuing. */
|
|
1026
|
+
.pw-sep{
|
|
1027
|
+
display:flex;align-items:center;gap:8px;margin:4px 0 2px;
|
|
1028
|
+
font-size:11.5px;color:var(--dim);
|
|
1029
|
+
}
|
|
1030
|
+
.pw-sep::after{content:"";flex:1;height:1px;background:var(--line)}
|
|
856
1031
|
.provider-advanced{margin-top:8px}
|
|
857
1032
|
.provider-advanced>summary{
|
|
858
1033
|
font-size:12px;color:var(--dim);cursor:pointer;list-style:none;padding:2px 0;
|
|
@@ -915,8 +1090,9 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
|
|
|
915
1090
|
measured to be dark, flat and mostly transparent — a logo with colour in it,
|
|
916
1091
|
or one carrying its own background, is left exactly as its owner drew it. */
|
|
917
1092
|
@media (prefers-color-scheme:dark){
|
|
918
|
-
.mk-logo.invert-dark{filter:invert(1)}
|
|
1093
|
+
:root:not([data-theme="light"]) .mk-logo.invert-dark{filter:invert(1)}
|
|
919
1094
|
}
|
|
1095
|
+
:root[data-theme="dark"] .mk-logo.invert-dark{filter:invert(1)}
|
|
920
1096
|
/* The fallback, and the reason the icon is allowed to fail: a lettered tile
|
|
921
1097
|
still tells two adjacent rows apart, which is the whole job. */
|
|
922
1098
|
.mk-mono{display:grid;place-items:center;font-size:13px;font-weight:600;color:#fff}
|
|
@@ -1512,6 +1688,13 @@ const ICON_PATHS = {
|
|
|
1512
1688
|
// both menus open upward, and both mark themselves with a caret that reads as
|
|
1513
1689
|
// "there is a list here" rather than one that promises a direction.
|
|
1514
1690
|
caret: ["M4.5 6.5 8 10l3.5-3.5"],
|
|
1691
|
+
// The three themes, drawn the same way the site draws them so the two
|
|
1692
|
+
// controls are recognisably the same control.
|
|
1693
|
+
themeSystem: ["M6 13.4h4"],
|
|
1694
|
+
themeLight: ["M8 1.4v1.4M8 13.2v1.4M1.4 8h1.4M13.2 8h1.4M3.4 3.4l1 1M11.6 11.6l1 1M12.6 3.4l-1 1M4.4 11.6l-1 1"],
|
|
1695
|
+
themeDark: ["M13.2 9.6A5.6 5.6 0 0 1 6.4 2.8a5.6 5.6 0 1 0 6.8 6.8Z"],
|
|
1696
|
+
themeSystemRects: [[1.8, 2.6, 12.4, 8.6, 1.6]],
|
|
1697
|
+
themeLightCircles: [[8, 8, 3.1]],
|
|
1515
1698
|
// A page with its corner turned back, which is the one mark that reads as
|
|
1516
1699
|
// "file" at thirteen pixels without any lines of text inside it.
|
|
1517
1700
|
file: [
|
|
@@ -1570,6 +1753,15 @@ function svgIcon(name, extra) {
|
|
|
1570
1753
|
}
|
|
1571
1754
|
// The radius is per-circle because the icons it serves disagree: a fork's
|
|
1572
1755
|
// node is a dot, a settings knob is a ring you look through.
|
|
1756
|
+
for (const [x, y, w, h, rx] of ICON_PATHS[name + "Rects"] || []) {
|
|
1757
|
+
const rect = document.createElementNS(NS, "rect");
|
|
1758
|
+
rect.setAttribute("x", String(x));
|
|
1759
|
+
rect.setAttribute("y", String(y));
|
|
1760
|
+
rect.setAttribute("width", String(w));
|
|
1761
|
+
rect.setAttribute("height", String(h));
|
|
1762
|
+
rect.setAttribute("rx", String(rx ?? 1.6));
|
|
1763
|
+
svg.appendChild(rect);
|
|
1764
|
+
}
|
|
1573
1765
|
for (const [cx, cy, r] of ICON_PATHS[name + "Circles"] || []) {
|
|
1574
1766
|
const circle = document.createElementNS(NS, "circle");
|
|
1575
1767
|
circle.setAttribute("cx", String(cx));
|
|
@@ -2121,7 +2313,7 @@ let P = null;
|
|
|
2121
2313
|
|
|
2122
2314
|
function turn(role) {
|
|
2123
2315
|
hideEmpty();
|
|
2124
|
-
const wrap = el("div", "turn " + role);
|
|
2316
|
+
const wrap = el("div", "turn column " + role);
|
|
2125
2317
|
wrap.appendChild(el("div", "who", role === "user" ? "you" : "assistant"));
|
|
2126
2318
|
P.node.appendChild(wrap);
|
|
2127
2319
|
follow();
|
|
@@ -2988,8 +3180,49 @@ function placeRowMenu(button) {
|
|
|
2988
3180
|
* reads the menu that is actually open instead of a list that has to be kept
|
|
2989
3181
|
* in step with it.
|
|
2990
3182
|
*/
|
|
3183
|
+
/**
|
|
3184
|
+
* The three themes, side by side and pressed rather than cycled.
|
|
3185
|
+
*
|
|
3186
|
+
* A cycling entry made the reader click twice to reach dark and told them the
|
|
3187
|
+
* current state only by reading a word. Three targets say what the options are
|
|
3188
|
+
* and which one is on, in one glance and one click — and it is the control the
|
|
3189
|
+
* site already uses in the same menu, so the two are recognisably the same
|
|
3190
|
+
* thing rather than two designs for one preference.
|
|
3191
|
+
*
|
|
3192
|
+
* Not a menuItem: this row is not a command, and closing the menu on click
|
|
3193
|
+
* would hide the change the click was for.
|
|
3194
|
+
*/
|
|
3195
|
+
function themeRow() {
|
|
3196
|
+
const row = el("div", "menu-theme");
|
|
3197
|
+
row.appendChild(el("span", "menu-label", t("theme.label")));
|
|
3198
|
+
const group = el("div", "theme-group");
|
|
3199
|
+
group.setAttribute("role", "group");
|
|
3200
|
+
group.setAttribute("aria-label", t("theme.label"));
|
|
3201
|
+
THEMES.forEach((option) => {
|
|
3202
|
+
const button = el("button", "theme-btn");
|
|
3203
|
+
button.type = "button";
|
|
3204
|
+
button.title = t("theme." + option);
|
|
3205
|
+
button.setAttribute("aria-label", t("theme." + option));
|
|
3206
|
+
button.setAttribute("aria-pressed", String(option === theme));
|
|
3207
|
+
button.appendChild(svgIcon("theme" + option[0].toUpperCase() + option.slice(1)));
|
|
3208
|
+
button.addEventListener("click", (e) => {
|
|
3209
|
+
e.stopPropagation();
|
|
3210
|
+
theme = option;
|
|
3211
|
+
applyTheme();
|
|
3212
|
+
// The menu stays open and repaints its own pressed states: the point of
|
|
3213
|
+
// three buttons is that the next one is one click away.
|
|
3214
|
+
[...group.children].forEach((node, i) => {
|
|
3215
|
+
node.setAttribute("aria-pressed", String(THEMES[i] === theme));
|
|
3216
|
+
});
|
|
3217
|
+
});
|
|
3218
|
+
group.appendChild(button);
|
|
3219
|
+
});
|
|
3220
|
+
row.appendChild(group);
|
|
3221
|
+
return row;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
2991
3224
|
function menuItem(label, opts) {
|
|
2992
|
-
const item = el("button", "menu-item" + (opts.danger ? " danger" : ""));
|
|
3225
|
+
const item = el("button", "menu-item" + (opts.danger ? " danger" : "") + (opts.cls ? " " + opts.cls : ""));
|
|
2993
3226
|
item.type = "button";
|
|
2994
3227
|
item.appendChild(el("span", "menu-label", label));
|
|
2995
3228
|
if (opts.key) {
|
|
@@ -3229,6 +3462,9 @@ async function loadModels() {
|
|
|
3229
3462
|
if (body.error) return void addNotice(body.error, "err");
|
|
3230
3463
|
const models = body.models || [];
|
|
3231
3464
|
providers = body.providers || [];
|
|
3465
|
+
// Kept for the connect dialog, which needs somewhere to send a person for a
|
|
3466
|
+
// key and is opened from places that have no response of their own to read.
|
|
3467
|
+
modelsSite = body.site || modelsSite;
|
|
3232
3468
|
|
|
3233
3469
|
// Grouped by vendor. Three things decide the order, in this order:
|
|
3234
3470
|
//
|
|
@@ -3289,6 +3525,90 @@ async function loadModels() {
|
|
|
3289
3525
|
modelPicker.set(modelOptions, current ? current.id : null);
|
|
3290
3526
|
}
|
|
3291
3527
|
|
|
3528
|
+
const connectionCards = new Map();
|
|
3529
|
+
|
|
3530
|
+
function connectionCard(card) {
|
|
3531
|
+
connectionCards.set(card.id, card);
|
|
3532
|
+
const target = pane(card.session);
|
|
3533
|
+
let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
|
|
3534
|
+
if (!box) {
|
|
3535
|
+
box = el("section", "row-card column connection-card");
|
|
3536
|
+
box.dataset.connectionId = card.id;
|
|
3537
|
+
box.setAttribute("aria-live", "polite");
|
|
3538
|
+
target.node.appendChild(box);
|
|
3539
|
+
}
|
|
3540
|
+
box.replaceChildren();
|
|
3541
|
+
const head = el("div", "connection-head");
|
|
3542
|
+
/*
|
|
3543
|
+
* The app's own mark, when Cloud sent one and it is https. A logo is the
|
|
3544
|
+
* fastest way to tell Gmail from a page pretending to be Gmail, which is
|
|
3545
|
+
* exactly why the URL is not taken on trust: it arrives from a vendor
|
|
3546
|
+
* catalogue, and an http or javascript: src would be a hole opened for
|
|
3547
|
+
* decoration. A card without one is a card with a blank square, never a
|
|
3548
|
+
* broken image.
|
|
3549
|
+
*/
|
|
3550
|
+
if (card.logo) {
|
|
3551
|
+
let safe;
|
|
3552
|
+
try { const parsed = new URL(card.logo); if (parsed.protocol === "https:") safe = parsed.href; } catch { safe = undefined; }
|
|
3553
|
+
if (safe) {
|
|
3554
|
+
const logo = el("img", "connection-logo");
|
|
3555
|
+
logo.src = safe; logo.alt = ""; logo.loading = "lazy"; logo.referrerPolicy = "no-referrer";
|
|
3556
|
+
logo.onerror = () => logo.remove();
|
|
3557
|
+
head.appendChild(logo);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
head.appendChild(el("span", "connection-name", card.label));
|
|
3561
|
+
box.appendChild(head);
|
|
3562
|
+
/*
|
|
3563
|
+
* The key when there is one, the text when there is not. The engine writes
|
|
3564
|
+
* both because the same card also reaches surfaces with no translation
|
|
3565
|
+
* table; here, where there is one, a card that spoke a different language
|
|
3566
|
+
* from the window around it was the bug.
|
|
3567
|
+
*/
|
|
3568
|
+
box.appendChild(el("p", "", card.messageKey ? t(card.messageKey) : card.message));
|
|
3569
|
+
if (card.state !== "required" && card.state !== "authorizing") return;
|
|
3570
|
+
const actions = el("div", "connection-actions");
|
|
3571
|
+
const start = el("button", "ghost", card.state === "authorizing" ? t("connect.open") : t("connect.action", { app: card.label }));
|
|
3572
|
+
const cancel = el("button", "ghost", t("connect.cancel"));
|
|
3573
|
+
actions.appendChild(start);
|
|
3574
|
+
actions.appendChild(cancel);
|
|
3575
|
+
box.appendChild(actions);
|
|
3576
|
+
start.onclick = async () => {
|
|
3577
|
+
start.disabled = true;
|
|
3578
|
+
// Reserve a window in the click gesture; credentials never enter its URL.
|
|
3579
|
+
const popup = window.open("about:blank", "_blank");
|
|
3580
|
+
if (popup) popup.opener = null;
|
|
3581
|
+
try {
|
|
3582
|
+
const result = await api("/cloud-connection", { id: card.id, action: "start" }, 60000);
|
|
3583
|
+
if (result.error) throw new Error(result.error);
|
|
3584
|
+
if (result.redirectUrl) {
|
|
3585
|
+
const url = new URL(result.redirectUrl);
|
|
3586
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("无效授权链接");
|
|
3587
|
+
if (popup && !popup.closed) popup.location.replace(url.href);
|
|
3588
|
+
else {
|
|
3589
|
+
const currentBox = target.node.querySelector('[data-connection-id="' + card.id + '"]');
|
|
3590
|
+
if (currentBox && connectionCards.get(card.id)?.state === "authorizing") {
|
|
3591
|
+
const link = el("a", "", t("connect.openLink"));
|
|
3592
|
+
link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer";
|
|
3593
|
+
currentBox.appendChild(link);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
} else if (popup) popup.close();
|
|
3597
|
+
} catch (error) {
|
|
3598
|
+
if (popup) popup.close();
|
|
3599
|
+
box.appendChild(el("p", "err", error.message || t("connect.failed")));
|
|
3600
|
+
} finally { start.disabled = false; }
|
|
3601
|
+
};
|
|
3602
|
+
cancel.onclick = async () => {
|
|
3603
|
+
cancel.disabled = true;
|
|
3604
|
+
try {
|
|
3605
|
+
const result = await api("/cloud-connection", { id: card.id, action: "cancel" });
|
|
3606
|
+
if (result.error) throw new Error(result.error);
|
|
3607
|
+
} catch { box.appendChild(el("p", "err", t("connect.cancelFailed"))); }
|
|
3608
|
+
finally { cancel.disabled = false; }
|
|
3609
|
+
};
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3292
3612
|
function renderHistory(entries, into) {
|
|
3293
3613
|
P = into || P;
|
|
3294
3614
|
const hasHistory = (entries || []).length > 0;
|
|
@@ -3337,6 +3657,9 @@ function renderHistory(entries, into) {
|
|
|
3337
3657
|
}
|
|
3338
3658
|
if (P.turn) turnActions(P.turn, entry.keepMessages);
|
|
3339
3659
|
}
|
|
3660
|
+
for (const card of connectionCards.values()) {
|
|
3661
|
+
if (pane(card.session) === P) connectionCard(card);
|
|
3662
|
+
}
|
|
3340
3663
|
atBottom = true;
|
|
3341
3664
|
log.scrollTop = log.scrollHeight;
|
|
3342
3665
|
}
|
|
@@ -3385,6 +3708,9 @@ events.onmessage = (message) => {
|
|
|
3385
3708
|
}
|
|
3386
3709
|
|
|
3387
3710
|
switch (event.type) {
|
|
3711
|
+
case "connection_card":
|
|
3712
|
+
connectionCard(event.card);
|
|
3713
|
+
break;
|
|
3388
3714
|
case "prompt": {
|
|
3389
3715
|
// The previews this tab made a moment ago, so a message just sent looks
|
|
3390
3716
|
// like the same message will after a reload — the transcript has not
|
|
@@ -3617,9 +3943,17 @@ let askState = null;
|
|
|
3617
3943
|
*/
|
|
3618
3944
|
function askRuleKey(request) {
|
|
3619
3945
|
const name = request.toolName || "";
|
|
3620
|
-
|
|
3946
|
+
// The spelling candidates() writes a rule in: capitalized, unless the name is
|
|
3947
|
+
// already snake_case and would only be disfigured by it.
|
|
3948
|
+
const capitalized = name.includes("_") ? name : name.charAt(0).toUpperCase() + name.slice(1);
|
|
3621
3949
|
const input = request.input;
|
|
3622
3950
|
if (!input || typeof input !== "object") return capitalized;
|
|
3951
|
+
// A platform app call is named by its app, and by the app's own tool when it
|
|
3952
|
+
// is executing one — see principalArgument() on the server.
|
|
3953
|
+
if (typeof input.app === "string") {
|
|
3954
|
+
const app = typeof input.tool === "string" ? input.app + ":" + input.tool : input.app;
|
|
3955
|
+
return capitalized + "(" + app + ")";
|
|
3956
|
+
}
|
|
3623
3957
|
const field =
|
|
3624
3958
|
name === "bash" ? "command"
|
|
3625
3959
|
: "path" in input ? "path"
|
|
@@ -5438,6 +5772,7 @@ async function showModels() {
|
|
|
5438
5772
|
if (body.error) return void panelBody.appendChild(panelError(body.error));
|
|
5439
5773
|
const models = body.models || [];
|
|
5440
5774
|
const list = body.providers || [];
|
|
5775
|
+
modelsSite = body.site || modelsSite;
|
|
5441
5776
|
|
|
5442
5777
|
const panel = el("div", "panel");
|
|
5443
5778
|
const head = el("div", "row-head");
|
|
@@ -5587,8 +5922,29 @@ async function showModels() {
|
|
|
5587
5922
|
// Who is signed in, when anyone is. A plan whose credential is an account
|
|
5588
5923
|
// has one question a "connected" tag cannot answer — whose — and it is the
|
|
5589
5924
|
// question a stale login gets wrong.
|
|
5925
|
+
// Which key, when a key is what this machine is using. Shown for the same
|
|
5926
|
+
// reason the account email is: "connected" does not say by what, and the
|
|
5927
|
+
// one thing somebody needs in order to revoke it is the name they gave it.
|
|
5928
|
+
if (provider.keyLabel) {
|
|
5929
|
+
cardBody.appendChild(
|
|
5930
|
+
el("div", "muted small", t("models.usingKey", { label: provider.keyLabel })),
|
|
5931
|
+
);
|
|
5932
|
+
}
|
|
5590
5933
|
if (provider.account) {
|
|
5591
|
-
|
|
5934
|
+
// Whose, and whether it still counts. The email comes from the stored
|
|
5935
|
+
// credential and survives it going stale, so claiming "signed in" on the
|
|
5936
|
+
// strength of it told people they had a session when they had the
|
|
5937
|
+
// remains of one — which is exactly how a dead sign-in button came to
|
|
5938
|
+
// look like a bug in the button rather than in the sentence above it.
|
|
5939
|
+
cardBody.appendChild(
|
|
5940
|
+
el(
|
|
5941
|
+
"div",
|
|
5942
|
+
"muted small",
|
|
5943
|
+
provider.configured
|
|
5944
|
+
? t("models.signedInAs", { email: provider.account })
|
|
5945
|
+
: t("models.wasSignedInAs", { email: provider.account }),
|
|
5946
|
+
),
|
|
5947
|
+
);
|
|
5592
5948
|
}
|
|
5593
5949
|
|
|
5594
5950
|
// An account-backed plan holds no key the subscriber could paste: the
|
|
@@ -5601,7 +5957,10 @@ async function showModels() {
|
|
|
5601
5957
|
// emailed code for a machine with no browser — and having a second copy of
|
|
5602
5958
|
// them on this card meant anything added to the sign-in had to be added
|
|
5603
5959
|
// twice, with the copy that was missed going quietly stale.
|
|
5604
|
-
|
|
5960
|
+
// Nothing to sign in to. The card says what is missing and how to supply
|
|
5961
|
+
// it, in the same terms the CLI uses, rather than showing a button whose
|
|
5962
|
+
// only possible outcome is a dialog that also cannot work.
|
|
5963
|
+
const signInButton = () => {
|
|
5605
5964
|
const go = el("button", "signin-site", t("models.signIn"));
|
|
5606
5965
|
go.type = "button";
|
|
5607
5966
|
go.addEventListener("click", (e) => {
|
|
@@ -5609,7 +5968,23 @@ async function showModels() {
|
|
|
5609
5968
|
e.stopPropagation();
|
|
5610
5969
|
openConnect(provider.id);
|
|
5611
5970
|
});
|
|
5612
|
-
|
|
5971
|
+
return go;
|
|
5972
|
+
};
|
|
5973
|
+
|
|
5974
|
+
if (provider.readiness === "no-account-system") {
|
|
5975
|
+
cardBody.appendChild(el("div", "muted small", t("models.noAccounts")));
|
|
5976
|
+
cardBody.appendChild(el("div", "muted small", t("models.noAccountsHow")));
|
|
5977
|
+
} else if (provider.readiness === "cannot-renew") {
|
|
5978
|
+
// The engine's own message about why the renewal failed. Shown rather
|
|
5979
|
+
// than summarised: this is somebody's own machine, and "could not be
|
|
5980
|
+
// renewed" without the reason is a sentence they can do nothing with.
|
|
5981
|
+
// The button stays, because signing in again is a real answer here —
|
|
5982
|
+
// unlike the case above, where there is nothing to sign in to.
|
|
5983
|
+
cardBody.appendChild(el("div", "muted small", t("models.cannotRenew")));
|
|
5984
|
+
if (provider.readinessDetail) cardBody.appendChild(el("div", "muted small", provider.readinessDetail));
|
|
5985
|
+
cardBody.appendChild(signInButton());
|
|
5986
|
+
} else if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
|
|
5987
|
+
cardBody.appendChild(signInButton());
|
|
5613
5988
|
}
|
|
5614
5989
|
|
|
5615
5990
|
// The key field, for every vendor that takes one — the account plans
|
|
@@ -5811,6 +6186,8 @@ function openReconnect(providerId) {
|
|
|
5811
6186
|
* paste and works everywhere, including where no mail is reachable. The
|
|
5812
6187
|
* sign-in is one line below it for the accounts that prefer it.
|
|
5813
6188
|
*/
|
|
6189
|
+
let modelsSite = "";
|
|
6190
|
+
|
|
5814
6191
|
function openConnect(providerId, thenModel) {
|
|
5815
6192
|
const provider = providers.find((p) => p.id === providerId);
|
|
5816
6193
|
if (!provider) return;
|
|
@@ -5823,7 +6200,44 @@ function openConnect(providerId, thenModel) {
|
|
|
5823
6200
|
// would be an advertisement in the way of the field they came to fill in.
|
|
5824
6201
|
document.getElementById("cn-aside").hidden = !provider.signIn;
|
|
5825
6202
|
document.getElementById("cn-title").textContent = provider.label || provider.id;
|
|
5826
|
-
|
|
6203
|
+
// What this dialog is answering. A credential that ran out and one that was
|
|
6204
|
+
// never created both arrive here, and telling someone to "sign in" when what
|
|
6205
|
+
// happened is "your session ended" leaves them wondering whether the first
|
|
6206
|
+
// one ever worked.
|
|
6207
|
+
// The provider's own description advertises signing in. Left in place on a
|
|
6208
|
+
// build that cannot sign in, it contradicts the sentence directly beneath it
|
|
6209
|
+
// — which is what made the dialog read as an error rather than as one of two
|
|
6210
|
+
// doors being shut.
|
|
6211
|
+
document.getElementById("cn-desc").textContent =
|
|
6212
|
+
provider.readiness === "no-account-system"
|
|
6213
|
+
? t("models.keyOnlyDesc")
|
|
6214
|
+
: provider.readiness === "expired"
|
|
6215
|
+
? t("models.expired")
|
|
6216
|
+
: provider.readiness === "cannot-renew"
|
|
6217
|
+
? t("models.cannotRenew")
|
|
6218
|
+
: provider.description || "";
|
|
6219
|
+
|
|
6220
|
+
// Nothing to sign in *to*, which is not the same as nothing to do. The
|
|
6221
|
+
// official plan takes two credentials: an account, which needs a project
|
|
6222
|
+
// this build does not know, and an oh- key, which is a bearer token for the
|
|
6223
|
+
// gateway and needs no project at all. So the dialog explains why the first
|
|
6224
|
+
// is unavailable and offers the second, rather than the Cancel button it
|
|
6225
|
+
// offered at first — which told someone with a perfectly good key that they
|
|
6226
|
+
// could not use this provider.
|
|
6227
|
+
//
|
|
6228
|
+
// An earlier version wrote a notice instead of opening anything. addNotice
|
|
6229
|
+
// puts its text in the transcript when no panel is up, so the explanation
|
|
6230
|
+
// arrived labelled as the assistant, reading as something the model said.
|
|
6231
|
+
const noAccounts = provider.readiness === "no-account-system";
|
|
6232
|
+
if (noAccounts) {
|
|
6233
|
+
// The left column sells having an account. Wrong thing to show beside a
|
|
6234
|
+
// sentence saying accounts do not exist in this build.
|
|
6235
|
+
document.getElementById("cn-aside").hidden = true;
|
|
6236
|
+
}
|
|
6237
|
+
// Undone on every open rather than only where it is set: this dialog is one
|
|
6238
|
+
// node reused by every provider, and a control hidden for one that stayed
|
|
6239
|
+
// hidden for the next is the classic way a shared dialog goes wrong.
|
|
6240
|
+
save.hidden = false;
|
|
5827
6241
|
|
|
5828
6242
|
// Which of the two ways in this dialog is for. Fixed once, not switchable:
|
|
5829
6243
|
// a provider whose credential is a key gets the key field, and one with an
|
|
@@ -5832,7 +6246,7 @@ function openConnect(providerId, thenModel) {
|
|
|
5832
6246
|
// window whose whole job is "sign in" — two unrelated things wearing one
|
|
5833
6247
|
// title. Pasting an oh- key is a settings action and lives on the
|
|
5834
6248
|
// provider's card, where the other vendors' key fields already are.
|
|
5835
|
-
const mode = provider.signIn ? "account" : "key";
|
|
6249
|
+
const mode = provider.signIn && !noAccounts ? "account" : "key";
|
|
5836
6250
|
let flow = null;
|
|
5837
6251
|
|
|
5838
6252
|
const field = document.createElement("input");
|
|
@@ -5897,7 +6311,12 @@ function openConnect(providerId, thenModel) {
|
|
|
5897
6311
|
* as though the code is the way in and the button above it is an aside.
|
|
5898
6312
|
*/
|
|
5899
6313
|
const focusPrimary = () => {
|
|
5900
|
-
|
|
6314
|
+
// Whatever the way in is. In account mode that is the website button; with
|
|
6315
|
+
// no account system it is the one that fetches a key, which is the same
|
|
6316
|
+
// shape of thing and deserves the same treatment. The field is where the
|
|
6317
|
+
// cursor goes only when pasting is what is left.
|
|
6318
|
+
const opener = mode === "account" && !flow ? siteButton : noAccounts ? keyButton : undefined;
|
|
6319
|
+
if (opener) opener.focus();
|
|
5901
6320
|
else field.focus();
|
|
5902
6321
|
};
|
|
5903
6322
|
|
|
@@ -5908,6 +6327,13 @@ function openConnect(providerId, thenModel) {
|
|
|
5908
6327
|
// Two lines, and the variable name kept whole: as one sentence it wrapped
|
|
5909
6328
|
// mid-identifier, which reads as a different variable than the one to set.
|
|
5910
6329
|
hint.replaceChildren(el("div", null, t("models.keyStored")));
|
|
6330
|
+
// Why the other half of this dialog is missing, and how to get it back.
|
|
6331
|
+
// Behind the mark rather than in the body: it is an answer to a question
|
|
6332
|
+
// most readers do not have, and the ones who do will look for it.
|
|
6333
|
+
if (noAccounts) {
|
|
6334
|
+
hint.appendChild(el("div", null, t("models.noAccounts")));
|
|
6335
|
+
hint.appendChild(el("div", null, t("models.noAccountsHow")));
|
|
6336
|
+
}
|
|
5911
6337
|
if ((provider.envVars || []).length) {
|
|
5912
6338
|
const line = el("div");
|
|
5913
6339
|
line.appendChild(document.createTextNode(t("models.keyEnv") + " "));
|
|
@@ -5943,7 +6369,11 @@ function openConnect(providerId, thenModel) {
|
|
|
5943
6369
|
// the reader choose by position, and position would put the emphasis on
|
|
5944
6370
|
// the wrong one here — in account mode the fallback is what sits in the
|
|
5945
6371
|
// actions row, and the way in is the button above the field.
|
|
5946
|
-
|
|
6372
|
+
// The same reasoning as account mode, now that key mode can also have a
|
|
6373
|
+
// button above the field: whichever control is the way in carries the
|
|
6374
|
+
// colour, and the actions row holds the fallback. Two filled buttons make
|
|
6375
|
+
// the reader choose by position, and position would pick the wrong one.
|
|
6376
|
+
save.classList.toggle("approve", mode === "key" && !noAccounts);
|
|
5947
6377
|
field.value = "";
|
|
5948
6378
|
focusPrimary();
|
|
5949
6379
|
};
|
|
@@ -6028,7 +6458,79 @@ function openConnect(providerId, thenModel) {
|
|
|
6028
6458
|
document.getElementById("cn-cancel").onclick = () => dialog.close();
|
|
6029
6459
|
document.getElementById("cn-x").onclick = () => dialog.close();
|
|
6030
6460
|
|
|
6031
|
-
|
|
6461
|
+
// Where the key comes from. The text above says a key is the way in and then
|
|
6462
|
+
// said nothing about getting one, which is half an instruction. siteUrl has a
|
|
6463
|
+
// default, so this resolves even on a build that knows no account project.
|
|
6464
|
+
// A button, not a link. The link left the person with a key on their
|
|
6465
|
+
// clipboard and a field to find again; the browser round trip ends with the
|
|
6466
|
+
// key already stored, because the site hands it back over the same loopback
|
|
6467
|
+
// the sign-in uses. It is also where keys are listed and revoked, so
|
|
6468
|
+
// "create one" and "revoke the one this machine was using" are the same
|
|
6469
|
+
// trip.
|
|
6470
|
+
//
|
|
6471
|
+
// Degrades to the link when this window was not reached over loopback: the
|
|
6472
|
+
// site will only redirect a credential to 127.0.0.1, so from a LAN address
|
|
6473
|
+
// the round trip cannot complete and copy-and-paste is the honest offer.
|
|
6474
|
+
let keyButton = null;
|
|
6475
|
+
const keyLink = () => {
|
|
6476
|
+
const href = (modelsSite || "https://oharness.ai") + "/" + LOCALE + "/dashboard/keys";
|
|
6477
|
+
const go = el("button", "signin-site", t("models.getOhKey"));
|
|
6478
|
+
go.type = "button";
|
|
6479
|
+
go.onclick = (e) => {
|
|
6480
|
+
e.preventDefault();
|
|
6481
|
+
const inDialog = (reason) => {
|
|
6482
|
+
hint.textContent = reason;
|
|
6483
|
+
hint.hidden = false;
|
|
6484
|
+
field.focus();
|
|
6485
|
+
};
|
|
6486
|
+
startWebsiteSignIn(
|
|
6487
|
+
provider.id,
|
|
6488
|
+
() => {
|
|
6489
|
+
if (dialog.open) void done("connected " + provider.id);
|
|
6490
|
+
},
|
|
6491
|
+
go,
|
|
6492
|
+
(reason) => {
|
|
6493
|
+
// Reached over a LAN address, so the site will not redirect a
|
|
6494
|
+
// credential back here. The tab still opens — copying a key across
|
|
6495
|
+
// is the way in that remains — and the field below is where it goes.
|
|
6496
|
+
window.open(href, "_blank", "noopener");
|
|
6497
|
+
inDialog(reason);
|
|
6498
|
+
},
|
|
6499
|
+
"key",
|
|
6500
|
+
inDialog,
|
|
6501
|
+
);
|
|
6502
|
+
};
|
|
6503
|
+
keyButton = go;
|
|
6504
|
+
// The same wrapper the website sign-in uses. signin-site carries flex:1,
|
|
6505
|
+
// which is right in a row and wrong in the column this body is — there it
|
|
6506
|
+
// resolves to flex-basis:0 on the height and squashes the button to 25px
|
|
6507
|
+
// above a 38px field. Putting it in the row it was written for fixes that
|
|
6508
|
+
// without changing a rule four other places depend on.
|
|
6509
|
+
const row = el("div", "keyrow");
|
|
6510
|
+
row.appendChild(go);
|
|
6511
|
+
return row;
|
|
6512
|
+
};
|
|
6513
|
+
|
|
6514
|
+
body.replaceChildren(
|
|
6515
|
+
// Why the account half of this dialog is missing, above the half that
|
|
6516
|
+
// still works. Absent for every provider that has nothing to explain.
|
|
6517
|
+
// One line and a button. The recipe for turning sign-in back on used to sit
|
|
6518
|
+
// here as a five-line paragraph, in front of everybody, for the few people
|
|
6519
|
+
// building their own fork — and a dialog whose first screenful is
|
|
6520
|
+
// configuration for somebody else is a dialog nobody reads. It moved
|
|
6521
|
+
// behind the mark, which is what the mark is for.
|
|
6522
|
+
// The button, and the sentence that makes the field beneath it an
|
|
6523
|
+
// alternative rather than a step. "Signing in is not available" moved
|
|
6524
|
+
// behind the mark with the rest of it: the description already says this
|
|
6525
|
+
// dialog connects with a key, so the line answered a question only
|
|
6526
|
+
// somebody expecting a sign-in would ask — and they are exactly who opens
|
|
6527
|
+
// the mark.
|
|
6528
|
+
...(noAccounts ? [keyLink(), el("div", "cn-note", t("models.orPasteKey"))] : []),
|
|
6529
|
+
site,
|
|
6530
|
+
orCode,
|
|
6531
|
+
field,
|
|
6532
|
+
hint,
|
|
6533
|
+
);
|
|
6032
6534
|
// Re-attached on every open: the actions row is markup, so a link left from
|
|
6033
6535
|
// the last vendor would otherwise point at their key page. The link is built
|
|
6034
6536
|
// fresh each time, so the previous one has to be taken out by hand — testing
|
|
@@ -6164,16 +6666,53 @@ async function loadAccount() {
|
|
|
6164
6666
|
// for separately so a gateway having a bad day cannot keep the row from
|
|
6165
6667
|
// saying who is signed in, which is the thing it is actually for.
|
|
6166
6668
|
let balance = null;
|
|
6669
|
+
// The plan, from the same answer the balance comes out of. It was already in
|
|
6670
|
+
// the response and thrown away, and it is the other half of the sentence:
|
|
6671
|
+
// "0 credits" reads as a problem to someone on a plan and as a fact of life
|
|
6672
|
+
// to someone who never subscribed, and the two want different next steps.
|
|
6673
|
+
let plan = null;
|
|
6674
|
+
// Whether there is a shop at all. A self-hosted gateway sells nothing, and
|
|
6675
|
+
// an entry that opens a paywall with no plans in it is worse than no entry.
|
|
6676
|
+
let sellsCredits = false;
|
|
6167
6677
|
void getJson("/billing").then((state) => {
|
|
6168
|
-
if (!state || !state.signedIn || state.unavailable
|
|
6169
|
-
|
|
6678
|
+
if (!state || !state.signedIn || state.unavailable) return;
|
|
6679
|
+
sellsCredits = true;
|
|
6680
|
+
if (state.balanceMicro !== undefined) {
|
|
6681
|
+
balance = t("paywall.balance", { credits: creditsOf(state.balanceMicro) });
|
|
6682
|
+
}
|
|
6683
|
+
const sub = state.subscription;
|
|
6684
|
+
plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
|
|
6170
6685
|
});
|
|
6171
6686
|
|
|
6172
6687
|
row.addEventListener("click", (e) => {
|
|
6173
6688
|
e.stopPropagation();
|
|
6174
6689
|
if (menuRow === row) return closeRowMenu();
|
|
6175
6690
|
const items = [];
|
|
6176
|
-
|
|
6691
|
+
// The two facts, in one block with a surface under them. They were two
|
|
6692
|
+
// dim lines the size of a footnote, above the things to click — which is
|
|
6693
|
+
// backwards: the balance is why somebody opened this menu, and the plan
|
|
6694
|
+
// is what decides which of the entries below is the one they want.
|
|
6695
|
+
if (balance || plan) {
|
|
6696
|
+
const head = el("div", "menu-head");
|
|
6697
|
+
if (balance) head.appendChild(el("div", "menu-head-main", balance));
|
|
6698
|
+
if (plan) head.appendChild(el("div", "menu-head-sub", plan));
|
|
6699
|
+
items.push(head);
|
|
6700
|
+
}
|
|
6701
|
+
// Buying, before the dashboard. Somebody who opened this menu after seeing
|
|
6702
|
+
// "0 credits" two lines up wants the shop, and the dashboard is a longer
|
|
6703
|
+
// way to the same place. It opens the paywall this app already has rather
|
|
6704
|
+
// than a browser tab: the balance refreshes itself there, and the turn
|
|
6705
|
+
// that could not be paid for is one click from being sent again.
|
|
6706
|
+
if (sellsCredits) {
|
|
6707
|
+
items.push(
|
|
6708
|
+
menuItem(t("account.getCredits"), {
|
|
6709
|
+
run: () => {
|
|
6710
|
+
closeRowMenu();
|
|
6711
|
+
openPaywall(true);
|
|
6712
|
+
},
|
|
6713
|
+
}),
|
|
6714
|
+
);
|
|
6715
|
+
}
|
|
6177
6716
|
items.push(
|
|
6178
6717
|
menuItem(t("account.dashboard"), {
|
|
6179
6718
|
run: () => {
|
|
@@ -6183,7 +6722,14 @@ async function loadAccount() {
|
|
|
6183
6722
|
if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
|
|
6184
6723
|
},
|
|
6185
6724
|
}),
|
|
6186
|
-
|
|
6725
|
+
menuItem(t("account.download"), {
|
|
6726
|
+
run: () => {
|
|
6727
|
+
closeRowMenu();
|
|
6728
|
+
if (body.site) window.open(body.site + "/" + LOCALE + "/download", "_blank", "noopener");
|
|
6729
|
+
},
|
|
6730
|
+
cls: "accent",
|
|
6731
|
+
}),
|
|
6732
|
+
themeRow(),
|
|
6187
6733
|
menuItem(t("account.signOut"), {
|
|
6188
6734
|
run: () => {
|
|
6189
6735
|
closeRowMenu();
|
|
@@ -6260,13 +6806,18 @@ function websiteSignIn(provider, onSignedIn, onUnavailable) {
|
|
|
6260
6806
|
* redirect back to, so the browser route is shut and the emailed code is the
|
|
6261
6807
|
* way in. The caller is the one that can put that where the user is looking.
|
|
6262
6808
|
*/
|
|
6263
|
-
function startWebsiteSignIn(providerId, onSignedIn, button, onUnavailable) {
|
|
6809
|
+
function startWebsiteSignIn(providerId, onSignedIn, button, onUnavailable, intent, onError) {
|
|
6264
6810
|
if (button) button.disabled = true;
|
|
6265
|
-
void api("/signin/browser", { provider: providerId, locale: LOCALE })
|
|
6811
|
+
void api("/signin/browser", { provider: providerId, locale: LOCALE, ...(intent ? { intent } : {}) })
|
|
6266
6812
|
.then((result) => {
|
|
6267
6813
|
if (!result || result.error) {
|
|
6268
6814
|
const message = (result && result.error) || "could not start the sign-in";
|
|
6269
6815
|
if (result && result.fallback === "code" && onUnavailable) return void onUnavailable(message);
|
|
6816
|
+
// A caller with a dialog open has somewhere better to put this.
|
|
6817
|
+
// addNotice writes to the transcript when no panel is up, so an error
|
|
6818
|
+
// from a button in a modal would arrive behind the modal, under an
|
|
6819
|
+
// assistant heading, reading as something the model said.
|
|
6820
|
+
if (onError) return void onError(message);
|
|
6270
6821
|
return void addNotice(message, "err");
|
|
6271
6822
|
}
|
|
6272
6823
|
// Only now: a sign-in that never started has nothing to wait for, and a
|
|
@@ -6297,6 +6848,23 @@ const PAYWALL_POLL_MS = 5000;
|
|
|
6297
6848
|
let paywallTimer = null;
|
|
6298
6849
|
|
|
6299
6850
|
/** Credits as a person reads them, from the micro-credits the ledger keeps. */
|
|
6851
|
+
/**
|
|
6852
|
+
* What a plan costs, in the currency it is sold in.
|
|
6853
|
+
*
|
|
6854
|
+
* The gateway has always sent amountCents and currency and the paywall threw
|
|
6855
|
+
* them away, so every row said how many credits and never how much money —
|
|
6856
|
+
* which is the one number somebody deciding between four rows needs.
|
|
6857
|
+
*/
|
|
6858
|
+
function moneyOf(cents, currency) {
|
|
6859
|
+
if (typeof cents !== "number") return "";
|
|
6860
|
+
return (cents / 100).toLocaleString(LOCALE, {
|
|
6861
|
+
style: "currency",
|
|
6862
|
+
currency: (currency || "usd").toUpperCase(),
|
|
6863
|
+
minimumFractionDigits: cents % 100 === 0 ? 0 : 2,
|
|
6864
|
+
maximumFractionDigits: 2,
|
|
6865
|
+
});
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6300
6868
|
function creditsOf(micro) {
|
|
6301
6869
|
return (Number(micro || 0) / 1000000).toLocaleString(LOCALE, {
|
|
6302
6870
|
minimumFractionDigits: 0,
|
|
@@ -6329,11 +6897,51 @@ function closePaywall() {
|
|
|
6329
6897
|
* never talks to — so the way this screen learns it worked is by watching the
|
|
6330
6898
|
* balance, which is also the only number that was ever the real answer.
|
|
6331
6899
|
*/
|
|
6332
|
-
|
|
6900
|
+
/** Which interval the paywall is showing. Monthly by default: it is the
|
|
6901
|
+
* smaller commitment, and this dialog is interrupting somebody who wanted to
|
|
6902
|
+
* send a message, not shopping. */
|
|
6903
|
+
/**
|
|
6904
|
+
* Light, dark, or whatever the system says.
|
|
6905
|
+
*
|
|
6906
|
+
* Three states rather than a switch, because following the system was the
|
|
6907
|
+
* behaviour before this existed and taking it away to gain a toggle is a
|
|
6908
|
+
* trade nobody asked for. Stored per browser: it describes this screen in
|
|
6909
|
+
* this room, not the account.
|
|
6910
|
+
*/
|
|
6911
|
+
const THEMES = ["system", "light", "dark"];
|
|
6912
|
+
let theme = "system";
|
|
6913
|
+
try {
|
|
6914
|
+
const saved = localStorage.getItem("oharness-theme");
|
|
6915
|
+
if (THEMES.indexOf(saved) > 0) theme = saved;
|
|
6916
|
+
} catch {
|
|
6917
|
+
// Private windows and blocked site data both throw here. Following the
|
|
6918
|
+
// system is the right answer when the preference cannot be read.
|
|
6919
|
+
}
|
|
6920
|
+
function applyTheme() {
|
|
6921
|
+
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
|
6922
|
+
else document.documentElement.setAttribute("data-theme", theme);
|
|
6923
|
+
try {
|
|
6924
|
+
if (theme === "system") localStorage.removeItem("oharness-theme");
|
|
6925
|
+
else localStorage.setItem("oharness-theme", theme);
|
|
6926
|
+
} catch {
|
|
6927
|
+
// Unstorable is not unusable: it applies for this tab and forgets after.
|
|
6928
|
+
}
|
|
6929
|
+
}
|
|
6930
|
+
applyTheme();
|
|
6931
|
+
|
|
6932
|
+
let pwInterval = "month";
|
|
6933
|
+
|
|
6934
|
+
/**
|
|
6935
|
+
* @param buying Opened deliberately, rather than by a turn that could not be
|
|
6936
|
+
* paid for. The screen is the same and the sentence at the top is not: a
|
|
6937
|
+
* dialog that says "this turn could not be paid for" to somebody who chose
|
|
6938
|
+
* Subscribe from a menu is describing a failure that did not happen.
|
|
6939
|
+
*/
|
|
6940
|
+
function openPaywall(buying) {
|
|
6333
6941
|
const dialog = document.getElementById("paywall");
|
|
6334
6942
|
const body = document.getElementById("pw-body");
|
|
6335
|
-
document.getElementById("pw-title").textContent = t("paywall.title");
|
|
6336
|
-
document.getElementById("pw-desc").textContent = t("paywall.desc");
|
|
6943
|
+
document.getElementById("pw-title").textContent = t(buying ? "paywall.titleBuy" : "paywall.title");
|
|
6944
|
+
document.getElementById("pw-desc").textContent = t(buying ? "paywall.descBuy" : "paywall.desc");
|
|
6337
6945
|
|
|
6338
6946
|
let site = "";
|
|
6339
6947
|
// Whether a checkout has been opened in a browser tab. Kept as state rather
|
|
@@ -6355,7 +6963,11 @@ function openPaywall() {
|
|
|
6355
6963
|
|
|
6356
6964
|
const buy = (plan, button) => {
|
|
6357
6965
|
button.disabled = true;
|
|
6358
|
-
void api("/billing/checkout", {
|
|
6966
|
+
void api("/billing/checkout", {
|
|
6967
|
+
plan: plan.id,
|
|
6968
|
+
locale: LOCALE,
|
|
6969
|
+
...(plan.buyCents ? { amount: plan.buyCents } : {}),
|
|
6970
|
+
})
|
|
6359
6971
|
.then((result) => {
|
|
6360
6972
|
if (!result || result.error || !result.url) {
|
|
6361
6973
|
return void addNotice((result && result.error) || "could not start the checkout", "err");
|
|
@@ -6423,26 +7035,157 @@ function openPaywall() {
|
|
|
6423
7035
|
);
|
|
6424
7036
|
|
|
6425
7037
|
const plans = state.plans || [];
|
|
7038
|
+
// payg is configured on the site, not bought here — the gateway refuses a
|
|
7039
|
+
// checkout for it — so it is not a row. The account page button below is
|
|
7040
|
+
// where it lives.
|
|
6426
7041
|
const packs = plans.filter((plan) => plan.kind === "topup");
|
|
6427
7042
|
const subs = plans.filter((plan) => plan.kind === "subscription");
|
|
6428
|
-
const ordered = subscribed ? packs.concat(subs) : subs.concat(packs);
|
|
6429
7043
|
|
|
6430
|
-
if (!
|
|
7044
|
+
if (!subs.length && !packs.length) {
|
|
6431
7045
|
body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
|
|
6432
7046
|
return;
|
|
6433
7047
|
}
|
|
6434
7048
|
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
7049
|
+
/**
|
|
7050
|
+
* What a yearly rung saves against paying monthly for the same tier.
|
|
7051
|
+
*
|
|
7052
|
+
* The only thing on these cards nobody can work out by looking. Monthly is
|
|
7053
|
+
* one price per credit across all four tiers — buying up buys volume, not
|
|
7054
|
+
* a discount — so a rate printed on those rows would be the same number
|
|
7055
|
+
* four times. The year is where the money actually differs, and seeing it
|
|
7056
|
+
* takes multiplying a price by twelve in your head.
|
|
7057
|
+
*
|
|
7058
|
+
* Computed rather than configured, so it cannot disagree with what the
|
|
7059
|
+
* checkout charges. Zero is returned as nothing: Starter's year is twelve
|
|
7060
|
+
* months at the same price, and a "Save 0%" badge on it would be a worse
|
|
7061
|
+
* lie than silence.
|
|
7062
|
+
*/
|
|
7063
|
+
const savingOn = (plan) => {
|
|
7064
|
+
if (plan.interval !== "year" || !plan.tier) return 0;
|
|
7065
|
+
const monthly = subs.find((p) => p.tier === plan.tier && p.interval === "month");
|
|
7066
|
+
if (!monthly || typeof monthly.amountCents !== "number" || typeof plan.amountCents !== "number") return 0;
|
|
7067
|
+
const full = monthly.amountCents * 12;
|
|
7068
|
+
if (full <= 0 || plan.amountCents >= full) return 0;
|
|
7069
|
+
return Math.round((1 - plan.amountCents / full) * 100);
|
|
7070
|
+
};
|
|
7071
|
+
|
|
7072
|
+
/** One row: what it is, how many credits, and how much money. */
|
|
7073
|
+
const planRow = (plan, suffix) => {
|
|
7074
|
+
const row = el("button", "pw-plan" + (plan.popular ? " featured" : ""));
|
|
6438
7075
|
row.type = "button";
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
7076
|
+
// Two lines rather than one, because two of these sit side by side on a
|
|
7077
|
+
// wide screen and the three-column row they used to be does not survive
|
|
7078
|
+
// being halved. The line that decides is on top.
|
|
7079
|
+
const top = el("span", "pw-plan-top");
|
|
7080
|
+
const name = el("span", "pw-plan-name");
|
|
7081
|
+
name.appendChild(document.createTextNode(plan.label || plan.id));
|
|
7082
|
+
// The catalogue marks one rung. Saying so is the whole of the advice
|
|
7083
|
+
// this dialog can give about which to pick.
|
|
7084
|
+
if (plan.popular) name.appendChild(el("span", "pw-tag", t("paywall.popular")));
|
|
7085
|
+
top.appendChild(name);
|
|
7086
|
+
const money = moneyOf(plan.amountCents, plan.currency);
|
|
7087
|
+
if (money) top.appendChild(el("span", "pw-plan-price", money + (suffix || "")));
|
|
7088
|
+
row.appendChild(top);
|
|
7089
|
+
// The pack's own label is already "2,000 credits", so repeating the
|
|
7090
|
+
// number underneath it said the same thing twice. Subscriptions need
|
|
7091
|
+
// the line — their names are tiers, not amounts.
|
|
7092
|
+
// The same sentence the pricing page prints, for the same reason it
|
|
7093
|
+
// prints it: "12,000 credits" does not say whether that arrives once or
|
|
7094
|
+
// twelve times, and the answer is the second one. A subscription is an
|
|
7095
|
+
// allowance, and a card that states a total without its period is
|
|
7096
|
+
// describing a top-up.
|
|
7097
|
+
const count = creditsOf(plan.creditsMicro);
|
|
7098
|
+
const amount =
|
|
7099
|
+
plan.kind === "subscription"
|
|
7100
|
+
? t(plan.interval === "year" ? "paywall.everyYear" : "paywall.everyMonth", { credits: count })
|
|
7101
|
+
: count + " " + t("paywall.unit");
|
|
7102
|
+
const saved = savingOn(plan);
|
|
7103
|
+
if ((plan.label || "") !== amount || saved) {
|
|
7104
|
+
const under = el("span", "pw-plan-under");
|
|
7105
|
+
if ((plan.label || "") !== amount) under.appendChild(el("span", null, amount));
|
|
7106
|
+
// Under the price it is about, not beside the name. The badge by the
|
|
7107
|
+
// name says which rung to pick; this one says what the year costs.
|
|
7108
|
+
if (saved) under.appendChild(el("span", "pw-save", t("paywall.save", { percent: saved })));
|
|
7109
|
+
row.appendChild(under);
|
|
7110
|
+
}
|
|
6442
7111
|
row.onclick = () => buy(plan, row);
|
|
6443
|
-
|
|
6444
|
-
}
|
|
6445
|
-
|
|
7112
|
+
return row;
|
|
7113
|
+
};
|
|
7114
|
+
|
|
7115
|
+
// Four tiers sold two ways came out as eight rows with four repeated
|
|
7116
|
+
// names and nothing saying which was which. They are the same four
|
|
7117
|
+
// offers; the interval is a choice about them, so it is a control rather
|
|
7118
|
+
// than eight buttons.
|
|
7119
|
+
const byInterval = { month: subs.filter((p) => p.interval === "month"),
|
|
7120
|
+
year: subs.filter((p) => p.interval === "year") };
|
|
7121
|
+
const intervals = ["month", "year"].filter((k) => byInterval[k].length > 0);
|
|
7122
|
+
const other = subs.filter((p) => p.interval !== "month" && p.interval !== "year");
|
|
7123
|
+
|
|
7124
|
+
if (subs.length) {
|
|
7125
|
+
if (intervals.length > 1) {
|
|
7126
|
+
const toggle = el("div", "pw-toggle");
|
|
7127
|
+
intervals.forEach((key) => {
|
|
7128
|
+
const tab = el("button", "pw-tab" + (key === pwInterval ? " on" : ""));
|
|
7129
|
+
tab.type = "button";
|
|
7130
|
+
tab.textContent = t(key === "year" ? "paywall.yearly" : "paywall.monthly");
|
|
7131
|
+
tab.onclick = () => {
|
|
7132
|
+
pwInterval = key;
|
|
7133
|
+
render(state);
|
|
7134
|
+
};
|
|
7135
|
+
toggle.appendChild(tab);
|
|
7136
|
+
});
|
|
7137
|
+
body.appendChild(toggle);
|
|
7138
|
+
}
|
|
7139
|
+
const showing = intervals.length > 1 ? byInterval[pwInterval] || [] : subs;
|
|
7140
|
+
const list = el("div", "pw-plans");
|
|
7141
|
+
showing.concat(other).forEach((plan) => {
|
|
7142
|
+
list.appendChild(planRow(plan, t(plan.interval === "year" ? "paywall.perYear" : "paywall.perMonth")));
|
|
7143
|
+
});
|
|
7144
|
+
body.appendChild(list);
|
|
7145
|
+
}
|
|
7146
|
+
|
|
7147
|
+
// The one-off, at the bottom, for whoever already has a plan. A top-up
|
|
7148
|
+
// covers a month that ran short rather than standing in for a subscription,
|
|
7149
|
+
// and the gateway refuses one from an account with none — so without a
|
|
7150
|
+
// plan this is the sentence saying so rather than rows that end in a
|
|
7151
|
+
// refusal after the click. The rows come back the moment there is a
|
|
7152
|
+
// subscription to top up.
|
|
7153
|
+
if (packs.length && !subscribed) {
|
|
7154
|
+
body.appendChild(el("div", "cn-note", t("paywall.needsPlan")));
|
|
7155
|
+
} else if (packs.length) {
|
|
7156
|
+
body.appendChild(el("div", "pw-sep", t("paywall.orOnce")));
|
|
7157
|
+
const once = el("div", "pw-plans");
|
|
7158
|
+
packs.forEach((plan) => {
|
|
7159
|
+
// A pack is one rung of a ladder the catalogue already defines, and
|
|
7160
|
+
// the paywall was showing the bottom one as though it were the whole
|
|
7161
|
+
// offer. The presets are the amounts the catalogue means people to
|
|
7162
|
+
// pick from; the rate beside them turns each into its credits, so the
|
|
7163
|
+
// rows cannot disagree with what the checkout grants.
|
|
7164
|
+
const ladder = plan.custom;
|
|
7165
|
+
const presets = ladder && ladder.presetCents ? ladder.presetCents : [];
|
|
7166
|
+
if (!presets.length) return void once.appendChild(planRow(plan, ""));
|
|
7167
|
+
presets.forEach((cents) => {
|
|
7168
|
+
const micro = (ladder.creditsMicroPerCent || 0) * cents;
|
|
7169
|
+
once.appendChild(
|
|
7170
|
+
planRow(
|
|
7171
|
+
{
|
|
7172
|
+
...plan,
|
|
7173
|
+
// Not the pack's own label: at $50 that would still read
|
|
7174
|
+
// "2,000 credits", which is the wrong number on the button.
|
|
7175
|
+
label: creditsOf(micro) + " " + t("paywall.unit"),
|
|
7176
|
+
creditsMicro: micro,
|
|
7177
|
+
amountCents: cents,
|
|
7178
|
+
// What the checkout is told. Absent on a rung that is the
|
|
7179
|
+
// pack's own price, so that purchase stays the plain one.
|
|
7180
|
+
...(cents === plan.amountCents ? {} : { buyCents: cents }),
|
|
7181
|
+
},
|
|
7182
|
+
"",
|
|
7183
|
+
),
|
|
7184
|
+
);
|
|
7185
|
+
});
|
|
7186
|
+
});
|
|
7187
|
+
body.appendChild(once);
|
|
7188
|
+
}
|
|
6446
7189
|
if (awaiting) body.appendChild(el("div", "cn-note", t("paywall.opened")));
|
|
6447
7190
|
};
|
|
6448
7191
|
|
|
@@ -6781,6 +7524,18 @@ async function showSettings() {
|
|
|
6781
7524
|
});
|
|
6782
7525
|
field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
|
|
6783
7526
|
|
|
7527
|
+
/*
|
|
7528
|
+
* Memory sits here rather than in the sidebar, and only when a plugin has
|
|
7529
|
+
* actually registered it: a row for a page that does not exist would be a
|
|
7530
|
+
* dead end, and the memory plugin can be switched off.
|
|
7531
|
+
*/
|
|
7532
|
+
for (const page of pluginPages.values()) {
|
|
7533
|
+
const open = el("button", "ghost", t("settings.memoryOpen"));
|
|
7534
|
+
open.type = "button";
|
|
7535
|
+
open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
|
|
7536
|
+
field(t("settings.memory"), "", t("settings.memoryHint"), open);
|
|
7537
|
+
}
|
|
7538
|
+
|
|
6784
7539
|
rulesField(settings.permissions || {});
|
|
6785
7540
|
auditField(settings.audit || { on: true, source: "user" });
|
|
6786
7541
|
filesField(settings.settingsPaths);
|
|
@@ -7632,6 +8387,157 @@ async function refreshRoutineCount() {
|
|
|
7632
8387
|
|
|
7633
8388
|
/** Sidebar entries that open a view of their own rather than a conversation. */
|
|
7634
8389
|
const PANEL_NAV = new Set(["nav-artifacts", "nav-models", "nav-routines", "nav-settings"]);
|
|
8390
|
+
const pluginPages = new Map();
|
|
8391
|
+
async function refreshPluginPages() {
|
|
8392
|
+
const session = activeId;
|
|
8393
|
+
const result = await getJson("/plugin-pages?session=" + encodeURIComponent(session || ""));
|
|
8394
|
+
if (activeId !== session) return;
|
|
8395
|
+
for (const id of pluginPages.keys()) { document.getElementById("nav-plugin-" + id)?.remove(); PANEL_NAV.delete("nav-plugin-" + id); }
|
|
8396
|
+
pluginPages.clear();
|
|
8397
|
+
/*
|
|
8398
|
+
* Remembered, not put in the sidebar.
|
|
8399
|
+
*
|
|
8400
|
+
* A plugin page is reached from a row in Settings instead. The sidebar is
|
|
8401
|
+
* the set of places a session goes back to, and Memory is not one of them —
|
|
8402
|
+
* it is read now and then and adjusted less often than that, which is the
|
|
8403
|
+
* same reason usage lives behind a Settings row rather than beside Models.
|
|
8404
|
+
* Every one of these becomes a SUB_PANEL, so the Settings row it came from
|
|
8405
|
+
* stays lit while it is open.
|
|
8406
|
+
*/
|
|
8407
|
+
for (const page of result.pages || []) {
|
|
8408
|
+
if (page.renderer !== "memory") continue;
|
|
8409
|
+
pluginPages.set(page.id, page);
|
|
8410
|
+
SUB_PANELS["nav-plugin-" + page.id] = "nav-settings";
|
|
8411
|
+
}
|
|
8412
|
+
}
|
|
8413
|
+
|
|
8414
|
+
/** Ids for label/control pairs; only has to be unique within the document. */
|
|
8415
|
+
let fieldSeq = 0;
|
|
8416
|
+
|
|
8417
|
+
async function showMemoryPage(id) {
|
|
8418
|
+
const session = activeId;
|
|
8419
|
+
const result = await getJson("/plugin-page?id=" + encodeURIComponent(id) + "&session=" + encodeURIComponent(session || ""));
|
|
8420
|
+
if (shownPanel !== "nav-plugin-" + id || activeId !== session) return;
|
|
8421
|
+
panelBody.replaceChildren(); panelNotes.replaceChildren();
|
|
8422
|
+
if (result.error && !result.entries) return void panelBody.appendChild(panelError(result.error));
|
|
8423
|
+
const panel = el("div", "panel memory-panel"); panelBody.appendChild(panel);
|
|
8424
|
+
panel.appendChild(el("h2", "", t("memory.title")));
|
|
8425
|
+
panel.appendChild(el("p", "muted", t("memory.intro")));
|
|
8426
|
+
panel.appendChild(el("p", "mono small memory-path", result.location));
|
|
8427
|
+
const status = el("p", "", !result.signedIn ? t("memory.localOnly") : result.accountChanged ? t("memory.accountChanged") : !result.enabled ? t("memory.syncOff") : result.syncing ? t("memory.syncing") : t("memory.syncOn"));
|
|
8428
|
+
panel.appendChild(status);
|
|
8429
|
+
if (result.lastSync) panel.appendChild(el("p", "muted small", t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) })));
|
|
8430
|
+
if (result.error) panel.appendChild(panelError(result.error));
|
|
8431
|
+
const act = async (input) => {
|
|
8432
|
+
try {
|
|
8433
|
+
const reply = await api("/plugin-page", { page: id, session, input }, 120000);
|
|
8434
|
+
if (reply.error) throw new Error(reply.error);
|
|
8435
|
+
await showMemoryPage(id);
|
|
8436
|
+
} catch (error) {
|
|
8437
|
+
if (activeId === session && shownPanel === "nav-plugin-" + id) {
|
|
8438
|
+
await showMemoryPage(id);
|
|
8439
|
+
panelBody.appendChild(panelError(error.message || t("memory.failed")));
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
8442
|
+
};
|
|
8443
|
+
const controls = el("div", ""); controls.style.cssText = "display:flex;gap:12px;align-items:center;margin:16px 0";
|
|
8444
|
+
const toggle = el("button", "ghost", result.enabled ? t("memory.turnOff") : t("memory.turnOn"));
|
|
8445
|
+
toggle.setAttribute("role", "switch"); toggle.setAttribute("aria-label", t("memory.switchLabel")); toggle.setAttribute("aria-checked", String(result.enabled));
|
|
8446
|
+
toggle.disabled = !result.enabled && (!result.signedIn || result.accountChanged);
|
|
8447
|
+
toggle.onclick = async () => { toggle.disabled = true; await act({ action: "enable", enabled: !result.enabled }); toggle.disabled = false; };
|
|
8448
|
+
controls.appendChild(toggle);
|
|
8449
|
+
if (result.signedIn) {
|
|
8450
|
+
const sync = el("button", "ghost", t("memory.syncNow")); sync.disabled = !result.ready || result.syncing;
|
|
8451
|
+
sync.onclick = async () => { sync.disabled = true; status.textContent = t("memory.syncing"); await act({ action: "sync" }); sync.disabled = false; };
|
|
8452
|
+
controls.appendChild(sync);
|
|
8453
|
+
}
|
|
8454
|
+
const refresh = el("button", "ghost", t("memory.refresh")); refresh.onclick = () => { void showMemoryPage(id); }; controls.appendChild(refresh);
|
|
8455
|
+
panel.appendChild(controls);
|
|
8456
|
+
for (const conflict of result.conflicts || []) {
|
|
8457
|
+
const row = el("section", "row-card"); row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
|
|
8458
|
+
for (const [label, note] of [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]]) {
|
|
8459
|
+
row.appendChild(el("h4", "", label)); const text = el("pre", "small", note ? note.summary + "\n" + note.body : t("memory.deleted")); text.style.whiteSpace = "pre-wrap"; row.appendChild(text);
|
|
8460
|
+
}
|
|
8461
|
+
for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")]]) {
|
|
8462
|
+
const button = el("button", "ghost", label); button.onclick = () => { void act({ action: "resolve", id: conflict.id, choice }); }; row.appendChild(button);
|
|
8463
|
+
}
|
|
8464
|
+
panel.appendChild(row);
|
|
8465
|
+
}
|
|
8466
|
+
const editor = (note, extra) => {
|
|
8467
|
+
const form = el("form", "memory-editor"), summary = el("input", ""), body = el("textarea", ""), tags = el("input", "");
|
|
8468
|
+
summary.placeholder = t("memory.summaryPlaceholder"); summary.value = note?.summary || ""; summary.required = true;
|
|
8469
|
+
body.placeholder = t("memory.bodyPlaceholder"); body.value = note?.body || ""; body.rows = 6; body.required = true;
|
|
8470
|
+
tags.placeholder = t("memory.tagsPlaceholder"); tags.value = (note?.tags || []).join(", ");
|
|
8471
|
+
/*
|
|
8472
|
+
* A label each, and a line saying what the field is for.
|
|
8473
|
+
*
|
|
8474
|
+
* Three boxes with only placeholders said nothing at all once they had
|
|
8475
|
+
* content in them, which is every note anyone opens to edit. And the fact
|
|
8476
|
+
* worth knowing here is not which box is which — it is that only the
|
|
8477
|
+
* summary is ever in front of the model, so a vague one makes the note
|
|
8478
|
+
* unfindable no matter how good the body is.
|
|
8479
|
+
*/
|
|
8480
|
+
const labelled = (control, label, hint) => {
|
|
8481
|
+
const group = el("div", "memory-field");
|
|
8482
|
+
const name = el("label", "memory-label", label);
|
|
8483
|
+
control.id = "mem-" + (fieldSeq += 1);
|
|
8484
|
+
name.htmlFor = control.id;
|
|
8485
|
+
group.appendChild(name);
|
|
8486
|
+
if (hint) group.appendChild(el("div", "muted small", hint));
|
|
8487
|
+
control.style.cssText = "display:block;width:100%;box-sizing:border-box";
|
|
8488
|
+
group.appendChild(control);
|
|
8489
|
+
return group;
|
|
8490
|
+
};
|
|
8491
|
+
form.appendChild(labelled(summary, t("memory.summaryLabel"), t("memory.summaryHint")));
|
|
8492
|
+
form.appendChild(labelled(body, t("memory.bodyLabel"), t("memory.bodyHint")));
|
|
8493
|
+
form.appendChild(labelled(tags, t("memory.tagsLabel"), t("memory.tagsHint")));
|
|
8494
|
+
const actions = el("div", "memory-actions");
|
|
8495
|
+
const save = el("button", "ghost", note ? t("memory.save") : t("memory.add")); save.type = "submit"; actions.appendChild(save);
|
|
8496
|
+
// Delete belongs beside Save, not stranded under the card: they are the two
|
|
8497
|
+
// things this form can do, and stacking them read as two separate sections.
|
|
8498
|
+
if (extra) actions.appendChild(extra);
|
|
8499
|
+
form.appendChild(actions);
|
|
8500
|
+
form.onsubmit = async (event) => { event.preventDefault(); save.disabled = true;
|
|
8501
|
+
await act({ action: "save", ...(note ? { id: note.id, expectedHash: note.hash } : {}), note: { summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean) } }); save.disabled = false;
|
|
8502
|
+
};
|
|
8503
|
+
return form;
|
|
8504
|
+
};
|
|
8505
|
+
const add = el("details", "row-card memory-note");
|
|
8506
|
+
const addHead = el("summary", "");
|
|
8507
|
+
addHead.appendChild(el("span", "chev"));
|
|
8508
|
+
addHead.appendChild(el("span", "memory-note-name", t("memory.add")));
|
|
8509
|
+
add.appendChild(addHead); add.appendChild(editor()); panel.appendChild(add);
|
|
8510
|
+
const search = el("input", "memory-search"); search.placeholder = t("memory.search"); search.setAttribute("aria-label", t("memory.search")); search.style.margin = "16px 0"; panel.appendChild(search);
|
|
8511
|
+
const list = el("div", ""); panel.appendChild(list);
|
|
8512
|
+
const draw = () => {
|
|
8513
|
+
list.replaceChildren(); const query = search.value.toLowerCase();
|
|
8514
|
+
const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ")).toLowerCase().includes(query));
|
|
8515
|
+
if (!entries.length) list.appendChild(el("p", "muted", t("memory.none")));
|
|
8516
|
+
for (const note of entries) {
|
|
8517
|
+
const row = el("details", "row-card memory-note");
|
|
8518
|
+
/*
|
|
8519
|
+
* Summary and id on one line, the way a settings row is built.
|
|
8520
|
+
*
|
|
8521
|
+
* The id is how the model refers to this note and how a person quotes it
|
|
8522
|
+
* in a bug report, so it stays visible — but it is a handle, not content,
|
|
8523
|
+
* and on a line of its own it read as the first thing in the note. On the
|
|
8524
|
+
* same line as the summary it also stops being a third left edge: the
|
|
8525
|
+
* disclosure marker indents the summary text, so anything under it lined
|
|
8526
|
+
* up with neither the marker nor the fields below.
|
|
8527
|
+
*/
|
|
8528
|
+
const head = el("summary", "");
|
|
8529
|
+
head.appendChild(el("span", "chev"));
|
|
8530
|
+
head.appendChild(el("span", "memory-note-name", note.summary));
|
|
8531
|
+
head.appendChild(el("span", "memory-id mono", note.id));
|
|
8532
|
+
row.appendChild(head);
|
|
8533
|
+
const remove = el("button", "ghost", t("memory.delete")); remove.type = "button";
|
|
8534
|
+
remove.onclick = () => { if (confirm(t("memory.confirmDelete"))) void act({ action: "delete", id: note.id, expectedHash: note.hash }); };
|
|
8535
|
+
row.appendChild(editor(note, remove));
|
|
8536
|
+
list.appendChild(row);
|
|
8537
|
+
}
|
|
8538
|
+
};
|
|
8539
|
+
search.oninput = draw; draw();
|
|
8540
|
+
}
|
|
7635
8541
|
|
|
7636
8542
|
/**
|
|
7637
8543
|
* Panels reached from a row inside another panel rather than from the sidebar.
|
|
@@ -7721,6 +8627,7 @@ function paintProject() {
|
|
|
7721
8627
|
|
|
7722
8628
|
/** Repaint whichever panel is on screen, if one is. */
|
|
7723
8629
|
function repaintPanel() {
|
|
8630
|
+
void refreshPluginPages();
|
|
7724
8631
|
if (panelView.hidden) return;
|
|
7725
8632
|
// Off what was opened, not off which sidebar row is lit: a sub-panel lights
|
|
7726
8633
|
// the row it was reached from, so asking the sidebar would answer "settings"
|
|
@@ -7730,6 +8637,7 @@ function repaintPanel() {
|
|
|
7730
8637
|
else if (shownPanel === "nav-routines") void showRoutines();
|
|
7731
8638
|
else if (shownPanel === "nav-settings") void showSettings();
|
|
7732
8639
|
else if (shownPanel === "usage") void showUsage();
|
|
8640
|
+
else if (shownPanel?.startsWith("nav-plugin-")) void showMemoryPage(shownPanel.slice("nav-plugin-".length));
|
|
7733
8641
|
}
|
|
7734
8642
|
|
|
7735
8643
|
async function loadProjects() {
|
|
@@ -8277,6 +9185,7 @@ void (async () => {
|
|
|
8277
9185
|
}
|
|
8278
9186
|
await loadSessions();
|
|
8279
9187
|
await refreshRoutineCount();
|
|
9188
|
+
await refreshPluginPages();
|
|
8280
9189
|
await refreshModelCount();
|
|
8281
9190
|
await loadAccount();
|
|
8282
9191
|
input.focus();
|