@oharness/server 0.4.0 → 0.5.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/dist/page.js CHANGED
@@ -205,6 +205,14 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
205
205
  }
206
206
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}}
207
207
  :root[data-theme="dark"] .row-menu{box-shadow:0 1px 2px #0000004d,0 8px 28px #00000080}
208
+ /* The account menu is as wide as the account row it opens from, edge to edge.
209
+ Sized for a session row's three commands, it came out a third narrower than
210
+ the row under it, so the two read as unrelated boxes; the width is set on
211
+ open from the row itself, and this lifts the caps that would clamp it. */
212
+ .row-menu.fit-anchor{min-width:0;max-width:none}
213
+ .row-menu.account-menu{
214
+ padding:10px;max-height:calc(100vh - 24px);overflow-y:auto;
215
+ }
208
216
  /*
209
217
  * Destructive is carried by the label's colour, not by a filled red bar. A
210
218
  * solid block reads as an alert that has already happened rather than as a
@@ -223,8 +231,24 @@ body.inset-titlebar aside header{padding-top:38px;-webkit-app-region:drag}
223
231
  .row-menu .menu-head{
224
232
  padding:9px 10px;margin-bottom:4px;border-radius:8px;background:var(--field);
225
233
  }
234
+ /* A section label, not a surface: the fill belongs to the account's facts
235
+ above, and on a one-word caption it read as a second card pressed against
236
+ Dashboard. Space above it does the separating instead. */
237
+ .row-menu .menu-head.menu-section{
238
+ margin:10px 0 2px;padding:4px 10px;background:transparent;
239
+ }
226
240
  .menu-head-main{font-size:14px;font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
227
241
  .menu-head-sub{margin-top:2px;font-size:12px;color:var(--dim)}
242
+ .menu-billing-actions{display:flex;gap:7px;margin-top:10px}
243
+ .menu-billing-action{
244
+ flex:1;min-height:30px;padding:6px 10px;border:0;border-radius:7px;
245
+ background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line);
246
+ font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;
247
+ }
248
+ .menu-billing-action:hover{background:var(--hover)}
249
+ .menu-billing-action.primary{background:var(--accent);color:var(--bg);box-shadow:none}
250
+ .menu-billing-action.primary:hover{opacity:.88}
251
+ .menu-billing-action:disabled{cursor:not-allowed;opacity:.45}
228
252
  /* One entry carrying the accent, for the one that is an offer rather than a
229
253
  destination. var(--field) was too close to the menu's own surface to read as
230
254
  anything in the light theme — a highlight nobody can see is worse than none,
@@ -411,6 +435,11 @@ svg.vglyph{width:15px;height:15px;opacity:1;
411
435
  the composer bar's 2px does not provide. */
412
436
  .menu-group .vmark,.menu-group .vmono,.menu-group .vglyph{width:13px;height:13px;font-size:8px}
413
437
  .picker-btn .vmark,.picker-btn .vmono,.picker-btn .vglyph{width:14px;height:14px;margin-right:5px}
438
+ /* The mode's glyph: a bare stroke, so it takes the row's icon weight and, on
439
+ the bar, the vendor mark's size and gap so the two pickers line up. */
440
+ .menu-item .mode-mark{opacity:.7}
441
+ .menu-item.on .mode-mark,.menu-item.cursor .mode-mark{opacity:1}
442
+ .picker-btn .mode-mark{width:14px;height:14px;margin-right:5px;opacity:.85}
414
443
  /* On a card the row is a title over a line of description, so the mark is
415
444
  sized against the block rather than against the type, and sits on the
416
445
  title's line instead of floating in the middle of the two. */
@@ -492,8 +521,18 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
492
521
  * Tabs are sessions; the drawer beside them shows one artifact whichever tab
493
522
  * is in front. Spanning both left a band of empty chrome above the drawer that
494
523
  * belonged to neither.
524
+ *
525
+ * The strip is the shelf the tabs stand on, in the sidebar's grey, and the
526
+ * front tab is cut from the page under it — the shape every browser uses.
527
+ * It used to be the other way round: a transparent strip on the white
528
+ * transcript with the front tab as a grey pill, so the row read as part of
529
+ * the conversation rather than as the chrome above it. No bottom padding,
530
+ * or a grey line would sit between the front tab and the page it is part of.
495
531
  */
496
- #tabs{display:flex;align-items:center;gap:2px;padding:0 12px 2px;overflow-x:auto;flex:0 0 auto}
532
+ #tabs{
533
+ display:flex;align-items:center;gap:2px;padding:6px 12px 0;overflow-x:auto;flex:0 0 auto;
534
+ background:var(--panel);
535
+ }
497
536
  #tabs:empty{display:none}
498
537
  .tab{
499
538
  display:flex;align-items:center;gap:7px;max-width:190px;height:30px;padding:0 6px 0 10px;
@@ -501,7 +540,7 @@ details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
501
540
  font:inherit;font-size:12.5px;cursor:pointer;flex:0 0 auto;
502
541
  }
503
542
  .tab:hover{background:var(--hover);color:var(--fg)}
504
- .tab.on{background:var(--panel);color:var(--fg)}
543
+ .tab.on{background:var(--bg);color:var(--fg)}
505
544
  .tab .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
506
545
  /* Which project a tab is in, shown only when the open tabs span more than one:
507
546
  two transcripts called "Untitled" in different trees are otherwise the same
@@ -562,7 +601,7 @@ body.panelled #composer,body.panelled #jump,body.panelled #changes-btn{display:n
562
601
  border-left:2px solid transparent;
563
602
  }
564
603
  .file:hover{background:var(--bg)}
565
- .file.active{background:var(--bg);border-left-color:var(--accent)}
604
+ .file.active{background:var(--sel)}
566
605
  .file .p{font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
567
606
  .file .n{font-size:11px;font-family:ui-monospace,monospace}
568
607
  .file .plus{color:var(--ok)} .file .minus{color:var(--err)}
@@ -638,7 +677,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
638
677
  /* Disabled means "not yet", not "never": it stays legible, and the row says
639
678
  why. Hiding it instead left people looking for a control that exists. */
640
679
  .switch:disabled{opacity:.45;cursor:default}
641
- .switch:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
680
+ .switch:focus-visible{outline:none;filter:brightness(.9)}
642
681
  .share-btn{
643
682
  border:1px solid var(--line);background:var(--field);color:var(--fg);
644
683
  border-radius:5px;padding:2px 8px;font:inherit;font-size:11px;cursor:pointer;
@@ -676,7 +715,7 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
676
715
  transcript look like a dialog, and next to the tool cards it sits between —
677
716
  which have none — it was the only thing drawing a box around itself. */
678
717
  .art-card:hover{background:var(--sel)}
679
- .art-card:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
718
+ .art-card:focus-visible{outline:none;background:var(--sel)}
680
719
  .art-card-mark{
681
720
  display:flex;align-items:center;justify-content:center;flex:0 0 auto;
682
721
  width:28px;height:28px;border-radius:7px;background:var(--field);color:var(--fg);
@@ -798,10 +837,10 @@ body.shelf-full #main,body.shelf-full aside,body.shelf-full #top{display:none}
798
837
  /* Filled, not outlined — the same shape .art-search uses. A box drawn with a
799
838
  * line around it is the one thing this site does not do, and three of them
800
839
  * stacked made the editor look like a form bolted onto the page rather than
801
- * part of the note. The focus ring stays: that is the one border worth having,
802
- * and it appears only while the field is being used. */
840
+ * part of the note. Focus deepens the fill rather than drawing a ring: no
841
+ * border marks a focused thing anywhere here. */
803
842
  .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}
804
- .memory-panel input:focus,.memory-panel textarea:focus{outline:2px solid var(--ring)}
843
+ .memory-panel input:focus,.memory-panel textarea:focus{outline:none;background:var(--field-focus)}
805
844
  .memory-panel textarea{resize:vertical;min-height:130px}
806
845
  .memory-panel summary{cursor:pointer;padding:6px 0;font-weight:500}
807
846
  .memory-panel .memory-path{overflow-wrap:anywhere;color:var(--dim)}
@@ -845,7 +884,7 @@ details.setting[open]>summary{border-radius:10px 10px 0 0}
845
884
  details.setting[open]>summary .chev{transform:rotate(90deg)}
846
885
  details.setting>summary .ghost{flex:0 0 auto;height:26px;font-size:12px;margin-left:0}
847
886
  details.setting>summary .switch{flex:0 0 auto}
848
- details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
887
+ details.setting>summary:focus-visible{outline:none;background:var(--hover)}
849
888
  /*
850
889
  * The artifacts panel: a search field over the whole catalogue.
851
890
  *
@@ -1001,10 +1040,20 @@ details.provider>summary:hover{background:var(--hover)}
1001
1040
  separate the account from the list — a hairline as well is a second answer
1002
1041
  to a question the whitespace had settled, and it is the only line left in a
1003
1042
  sidebar that has none. */
1043
+ /* A column, not a row. Signed in this used to hold the account button and
1044
+ nothing else, and now a subscriber gets a Top up row above it — the thing
1045
+ a subscriber does most often here, which had been the fourth entry of a
1046
+ menu behind the name. */
1004
1047
  #account{
1005
- display:flex;align-items:center;gap:8px;
1048
+ display:flex;flex-direction:column;align-items:stretch;gap:2px;
1006
1049
  flex:0 0 auto;padding:8px;
1007
1050
  }
1051
+ /* 10px across rather than the nav rows' 8: the account row under it starts
1052
+ with a 24px avatar inset 6px, and at 10px this row's 16px icon lands on
1053
+ the avatar's centre line rather than 4px to the left of it. The balance
1054
+ sits in the aside slot the nav rows keep for a count, because it is what
1055
+ decides whether to click. */
1056
+ #account .topup{width:100%;padding:0 10px}
1008
1057
  /* Signed out this holds one nav row and nothing else, so the padding that
1009
1058
  frames an account goes away and the row sits flush with the ones above it.
1010
1059
  8px across, matching the nav list above, so the icon lands on the same
@@ -1015,7 +1064,8 @@ details.provider>summary:hover{background:var(--hover)}
1015
1064
  taller than the nav rows above: a footer is allowed to be the roomiest row
1016
1065
  in the sidebar, and this one has to hold a click aimed at from the corner. */
1017
1066
  #account.out{padding:4px 8px 10px}
1018
- #account.out .nav-item{width:100%;height:34px}
1067
+ #account .nav-item{width:100%}
1068
+ #account.out .nav-item{height:34px}
1019
1069
  /* The whole row is the control. It used to be a label with a Sign out button
1020
1070
  parked beside it, which gave the rarest action in the sidebar the most
1021
1071
  reliable target in it, and left the name — the thing worth clicking, because
@@ -1083,15 +1133,15 @@ button.signin-site:disabled{opacity:.4;cursor:default}
1083
1133
  * The site's pricing page settled this and says why in its own stylesheet: the
1084
1134
  * offers should read as columns of type rather than as a row of frames, and on
1085
1135
  * a screen that is mostly numbers the frames are the loudest thing on it. So
1086
- * the fill goes quiet and the recommended rung keeps the one outline, which is
1087
- * what makes it a mark rather than a shade somebody has to notice.
1136
+ * the fill goes quiet and the recommended rung takes a deeper fill. No outline
1137
+ * marks anything here, the recommended rung included.
1088
1138
  */
1089
1139
  button.pw-plan{
1090
1140
  display:flex;flex-direction:column;align-items:stretch;gap:3px;width:100%;
1091
1141
  min-height:52px;padding:10px 12px;border:1px solid transparent;border-radius:10px;
1092
1142
  background:var(--field);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
1093
1143
  }
1094
- button.pw-plan.featured{border-color:var(--border-strong,var(--fg));background:var(--sel)}
1144
+ button.pw-plan.featured{background:var(--sel)}
1095
1145
  .pw-plan-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
1096
1146
  button.pw-plan:hover{background:var(--sel)}
1097
1147
  button.pw-plan.featured:hover{background:var(--hover)}
@@ -1612,7 +1662,7 @@ textarea.boxed{
1612
1662
  padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
1613
1663
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
1614
1664
  }
1615
- textarea.boxed:focus{border-color:var(--dim)}
1665
+ textarea.boxed:focus{outline:none;background:var(--field-focus)}
1616
1666
  .composer-bar{display:flex;align-items:center;gap:6px;margin-top:10px}
1617
1667
  .composer.drag{background:var(--field-focus);outline:2px dashed var(--line);outline-offset:-6px}
1618
1668
  .icon-btn{
@@ -1694,6 +1744,13 @@ textarea.boxed:focus{border-color:var(--dim)}
1694
1744
  the arrow keys. A ring around a popup that was just clicked open says
1695
1745
  nothing the row highlight is not already saying. */
1696
1746
  .menu:focus{outline:none}
1747
+ /* No ring and no border marks a focused or selected thing anywhere in the app.
1748
+ That includes the browser's own focus outline, which drew a blue ring round
1749
+ the account button while its menu was open. Keyboard focus is carried by a
1750
+ fill instead; the zero-specificity selector leaves every control that
1751
+ already paints its own background alone. */
1752
+ :focus,:focus-visible{outline:none}
1753
+ :where(button,a,summary,[role="button"],[tabindex]):focus-visible{background-color:var(--hover)}
1697
1754
  /* The triangle the group headers use, turned on a row that leads somewhere:
1698
1755
  the + opens onto four offers, and three of them go one screen deeper. */
1699
1756
  .menu-item .chev{
@@ -1805,14 +1862,134 @@ dialog::backdrop{background:rgba(0,0,0,.55)}
1805
1862
  padding:8px 18px 16px;font-size:11.5px;line-height:1.6;color:var(--dim);min-height:34px;
1806
1863
  }
1807
1864
 
1865
+ /*
1866
+ * Phone-only chrome, declared before the breakpoint that shows it: a base
1867
+ * rule written after the media query wins the cascade, which is how the
1868
+ * burger spent a release at display:none on every width.
1869
+ */
1870
+ #burger{
1871
+ display:none;width:36px;height:36px;margin-left:-8px;border:0;border-radius:8px;
1872
+ background:none;color:var(--fg);font:inherit;font-size:18px;cursor:pointer;place-items:center;
1873
+ }
1874
+ #burger:hover{background:var(--hover)}
1875
+ #nav-close{
1876
+ display:none;width:32px;height:32px;margin-left:auto;border:0;border-radius:8px;
1877
+ background:transparent;color:var(--dim);cursor:pointer;padding:0;place-items:center;
1878
+ }
1879
+ #nav-close svg{width:14px;height:14px}
1880
+ #nav-close:hover{background:var(--hover);color:var(--fg)}
1881
+ #scrim{display:none;position:fixed;inset:0;z-index:19;background:#00000059}
1882
+ #review-close{
1883
+ display:none;width:32px;height:32px;border:0;border-radius:8px;background:transparent;
1884
+ color:var(--fg);cursor:pointer;padding:0;place-items:center;margin-left:8px;
1885
+ }
1886
+ #review-close svg{width:16px;height:16px}
1887
+
1888
+ /*
1889
+ * A phone.
1890
+ *
1891
+ * The desktop layout is three columns that share one window; a phone has room
1892
+ * for one of them at a time. The sidebar becomes a drawer over the transcript,
1893
+ * the review and artifact panes take the whole screen while they are open,
1894
+ * and the composer's bar is split in two: where the session runs and what it
1895
+ * may do sit above the text, the model and the send button below it.
1896
+ */
1808
1897
  @media (max-width:760px){
1809
- aside{position:fixed;inset:0 auto 0 0;z-index:20;transform:translateX(-100%);transition:transform .2s}
1810
- aside.open{transform:none}
1811
- #burger{display:block}
1812
- /* The burger lives here, so the bar has a reason to exist again. */
1813
- #top{padding:10px 18px}
1898
+ body>aside{
1899
+ position:fixed;inset:0 auto 0 0;z-index:20;width:min(280px,84vw);flex-basis:auto;
1900
+ transform:translateX(-100%);transition:transform .2s;
1901
+ padding-bottom:env(safe-area-inset-bottom);
1902
+ }
1903
+ body>aside.open{transform:none;box-shadow:0 0 0 1px #0000000a,0 12px 40px #00000040}
1904
+ body.nav-open #scrim{display:block}
1905
+ #nav-close{display:grid}
1906
+ #burger{display:grid}
1907
+ #top{padding:6px 10px}
1908
+ #top:has(#changes-btn:not([hidden])){padding:6px 10px}
1909
+
1910
+ /* Both panes are the screen while open, and each gets a way out of its own:
1911
+ the top bar with the changes button is underneath them. */
1912
+ #review.open,#shelf.open{
1913
+ position:fixed;inset:0;z-index:50;width:100%;height:100%;flex:none;transition:none;
1914
+ }
1915
+ #review-head{padding:10px 10px 8px 16px}
1916
+ #review-close{display:grid}
1917
+ #files{max-height:40%}
1918
+ #shelf-bar{flex-wrap:wrap;row-gap:4px}
1919
+
1920
+ #log{padding:16px 14px 8px}
1921
+ #panel-view{padding:20px 14px 24px}
1922
+ .empty-brand{gap:12px;margin-bottom:22px}
1923
+ .brand-mark,.brand-mark svg{width:48px;height:48px}
1924
+ .brand-name{font-size:30px}
1925
+ #empty{margin-top:4vh}
1926
+
1927
+ /* The composer. The bar's children become items of the box itself so they
1928
+ can be dealt into two rows around the field; the bar element stays in the
1929
+ markup for the desktop layout and the scripts that hang menus off it. */
1930
+ form{padding:6px 12px calc(12px + env(safe-area-inset-bottom))}
1931
+ .composer{
1932
+ position:relative;display:flex;flex-wrap:wrap;align-items:center;
1933
+ column-gap:6px;row-gap:8px;padding:10px 12px 10px;
1934
+ }
1935
+ .composer-bar{display:contents}
1936
+ #attachments{order:-3;flex:0 0 100%;margin-bottom:0}
1937
+ #workspace-picker,#mode-picker{order:-2}
1938
+ .input-wrap{order:-1;flex:0 0 100%}
1939
+ .composer .picker{position:static;min-width:0}
1940
+ .composer .picker-btn{max-width:100%;height:32px}
1941
+ #send,#stop{flex:0 0 auto}
1942
+ textarea{max-height:32dvh}
1943
+ /* Menus span the box rather than hang off one picker: from a picker on the
1944
+ right they otherwise open past the edge, where overflow:hidden loses them. */
1945
+ .composer .menu,#attach-picker .menu{
1946
+ left:0;right:0;width:auto;min-width:0;max-width:none;bottom:calc(100% + 8px);
1947
+ }
1948
+ .menu-body{max-height:min(50dvh,340px)}
1949
+ #jump{right:14px;bottom:160px}
1950
+
1951
+ /* Dialogs, with the keyboard up: vh is the height without it. */
1952
+ #market{max-height:82dvh}
1953
+ #provider-picker,#browse{max-height:76dvh}
1954
+ #project-picker{max-height:70dvh}
1955
+ .mk-grid{grid-template-columns:minmax(0,1fr)}
1956
+ .mk-search{padding-right:44px}
1957
+ .ask-body{max-height:min(260px,40dvh)}
1958
+ .ask-line .n{flex:0 0 28px;padding-right:6px}
1959
+ }
1960
+
1961
+ /*
1962
+ * A finger, not a pointer.
1963
+ *
1964
+ * Hover reveals nothing on a touch screen, so what the desktop shows on hover
1965
+ * is shown outright; and a field under 16px makes iOS zoom the page when it
1966
+ * is focused, so every field is 16px here whatever it is elsewhere.
1967
+ */
1968
+ @media (hover:none) and (pointer:coarse){
1969
+ .session .more,.fold-row .more{display:grid;width:28px;height:28px}
1970
+ .proj-forget{opacity:1}
1971
+ .actions{position:static;opacity:1;margin-top:4px}
1972
+ .turn{margin-bottom:14px}
1973
+ .act{padding:6px 10px}
1974
+ .tab .x{opacity:.55;width:24px;height:24px}
1975
+ #tab-add{width:32px;height:32px}
1976
+ .tab{height:34px}
1977
+ .nav-item,.session,.fold,.project{height:38px}
1978
+ .show-more{height:32px}
1979
+ .icon-btn{width:34px;height:34px}
1980
+ button.primary{width:36px;height:36px}
1981
+ button.ghost,a.ghost{height:36px}
1982
+ .att .x{padding:4px 6px}
1983
+ .cn-x,.mk-close,.theme-btn{width:36px;height:36px}
1984
+ .cn-why{width:28px;height:28px}
1985
+ .switch{width:40px;height:24px}
1986
+ .dlg-actions button{padding:10px 14px}
1987
+ input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),select,textarea,
1988
+ .menu-search input,.mk-search,.art-search,.keyrow input,.cn-body input,.pp-head input,
1989
+ textarea.boxed{font-size:16px}
1990
+ /* The chip layer under the field has to keep the field's metrics. */
1991
+ #input-mirror{font-size:16px}
1814
1992
  }
1815
- #burger{display:none;background:none;border:0;color:var(--fg);font:inherit;cursor:pointer;font-size:18px}
1816
1993
  `;
1817
1994
  const APP_SCRIPT = String.raw `
1818
1995
  const STRINGS = window.__STRINGS;
@@ -1838,6 +2015,10 @@ if (window.oharness && window.oharness.insetTitlebar) {
1838
2015
  /** Whether this page is the desktop window rather than a browser tab. */
1839
2016
  const IS_DESKTOP = Boolean(window.oharness && window.oharness.desktop);
1840
2017
 
2018
+ /** Whether the page is laid out for a phone right now; the stylesheet's breakpoint. */
2019
+ const PHONE_QUERY = window.matchMedia("(max-width:760px)");
2020
+ const isPhone = () => PHONE_QUERY.matches;
2021
+
1841
2022
  // --- language --------------------------------------------------------------
1842
2023
 
1843
2024
  /**
@@ -1944,6 +2125,31 @@ const ICON_PATHS = {
1944
2125
  "M14 10a1.33 1.33 0 0 1-1.33 1.33H4.67L2 14V3.33A1.33 1.33 0 0 1 3.33 2h9.34" +
1945
2126
  "A1.33 1.33 0 0 1 14 3.33Z",
1946
2127
  ],
2128
+ // The five permission modes. Five short words in a column look alike until
2129
+ // they are read; a glyph in front of each lets the one in use be found by
2130
+ // shape from the bar, the way the vendor mark does for the model.
2131
+ //
2132
+ // Manual is by hand, and a raised palm is also the sign for "wait": three
2133
+ // fingers, a thumb, and the heel of the hand in one sweep. Four fingers
2134
+ // would be the honest count, but at fifteen pixels the fourth turns the
2135
+ // top edge into a comb.
2136
+ modeAsk: [
2137
+ "M12 7.4V4a1.33 1.33 0 0 0-2.67 0",
2138
+ "M9.33 6.7V2.7a1.33 1.33 0 0 0-2.66 0v1.3",
2139
+ "M6.67 7V4A1.33 1.33 0 0 0 4 4v5.33",
2140
+ "M12 5.33a1.33 1.33 0 1 1 2.67 0v4a5.33 5.33 0 0 1-5.34 5.34H8" +
2141
+ "c-1.87 0-3-.58-4-1.56l-2.4-2.4a1.33 1.33 0 0 1 1.89-1.89L4.67 10",
2142
+ ],
2143
+ // Accept edits lets the pencil through.
2144
+ modeEdits: ["M11.3 2.7 13.3 4.7 5.3 12.7H3.3v-2Z", "M9.8 4.2l2 2"],
2145
+ // Plan is read-only: an eye, which looks and does not touch.
2146
+ modePlan: ["M1.6 8s2.4-4.4 6.4-4.4S14.4 8 14.4 8s-2.4 4.4-6.4 4.4S1.6 8 1.6 8Z"],
2147
+ modePlanCircles: [[8, 8, 2.1]],
2148
+ // Auto waves the routine through: a bolt.
2149
+ modeAuto: ["M8.8 1.6 3.6 9h4l-.7 5.4L12.4 7h-4Z"],
2150
+ // Bypass leaves the gate open: a padlock with its shackle swung clear.
2151
+ modeBypass: ["M5.4 7.4V5.2a2.6 2.6 0 0 1 5-1"],
2152
+ modeBypassRects: [[3.2, 7.4, 9.6, 6.2, 1.6]],
1947
2153
  };
1948
2154
 
1949
2155
  function svgIcon(name, extra) {
@@ -2468,12 +2674,16 @@ function makePicker(id, onPick, extra) {
2468
2674
  return api;
2469
2675
  }
2470
2676
 
2677
+ // Drawn glyphs rather than vendor tiles: a mode is chrome, not a party being
2678
+ // paid, so it gets the bare stroke every other menu icon has.
2679
+ const modeMark = (icon) => (extra) => svgIcon(icon, "mode-mark" + (extra ? " " + extra : ""));
2680
+
2471
2681
  const MODES = [
2472
- { value: "default", label: "Manual", sub: "ask first" },
2473
- { value: "acceptEdits", label: "Accept edits", sub: "edits pass" },
2474
- { value: "plan", label: "Plan", sub: "read-only" },
2475
- { value: "auto", label: "Auto", sub: "routine passes" },
2476
- { value: "bypassPermissions", label: "Bypass", sub: "all pass" },
2682
+ { value: "default", label: "Manual", sub: "ask first", mark: modeMark("modeAsk") },
2683
+ { value: "acceptEdits", label: "Accept edits", sub: "edits pass", mark: modeMark("modeEdits") },
2684
+ { value: "plan", label: "Plan", sub: "read-only", mark: modeMark("modePlan") },
2685
+ { value: "auto", label: "Auto", sub: "routine passes", mark: modeMark("modeAuto") },
2686
+ { value: "bypassPermissions", label: "Bypass", sub: "all pass", mark: modeMark("modeBypass") },
2477
2687
  ];
2478
2688
 
2479
2689
  const modelPicker = makePicker(
@@ -3493,6 +3703,8 @@ function closeRowMenu() {
3493
3703
  menuRow = null;
3494
3704
  rowMenu.hidden = true;
3495
3705
  rowMenu.replaceChildren();
3706
+ rowMenu.classList.remove("fit-anchor", "account-menu");
3707
+ rowMenu.style.width = "";
3496
3708
  }
3497
3709
 
3498
3710
  /**
@@ -3505,8 +3717,9 @@ function placeRowMenu(button) {
3505
3717
  const anchor = button.getBoundingClientRect();
3506
3718
  const box = rowMenu.getBoundingClientRect();
3507
3719
  const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
3508
- const below = anchor.bottom + 4;
3509
- const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - 4 : below;
3720
+ const gap = rowMenu.classList.contains("account-menu") ? 8 : 4;
3721
+ const below = anchor.bottom + gap;
3722
+ const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - gap : below;
3510
3723
  rowMenu.style.left = Math.max(8, left) + "px";
3511
3724
  rowMenu.style.top = Math.max(8, top) + "px";
3512
3725
  }
@@ -4176,7 +4389,9 @@ events.onmessage = (message) => {
4176
4389
  if (wasSelected) void showArtifact(event.artifact.id);
4177
4390
  // First artifact of the session opens the drawer, the way the first file
4178
4391
  // change makes the review button appear. Later ones do not steal focus.
4179
- if (artifacts.length === 1) shelf.classList.add("open");
4392
+ // Not on a phone, where the drawer is the whole screen: it would cover
4393
+ // the reply that produced the artifact.
4394
+ if (artifacts.length === 1 && !isPhone()) shelf.classList.add("open");
4180
4395
  break;
4181
4396
  }
4182
4397
 
@@ -4721,7 +4936,7 @@ function attachRows() {
4721
4936
  // draws; a plug in front of three names is three rows told apart by
4722
4937
  // reading.
4723
4938
  mark: kind === "connectors" ? entry.name : undefined,
4724
- label: entry.name,
4939
+ label: entry.title || entry.name,
4725
4940
  note: entry.description || "",
4726
4941
  ref: {
4727
4942
  kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
@@ -4828,13 +5043,14 @@ function attachRows() {
4828
5043
  .filter((plugin) =>
4829
5044
  !query ||
4830
5045
  plugin.name.toLowerCase().includes(query) ||
5046
+ (plugin.title || "").toLowerCase().includes(query) ||
4831
5047
  (plugin.description || "").toLowerCase().includes(query),
4832
5048
  )
4833
5049
  .slice(0, 4)
4834
5050
  .map((plugin) => ({
4835
5051
  type: "plugin",
4836
5052
  icon: "plugin",
4837
- label: plugin.name,
5053
+ label: plugin.title || plugin.name,
4838
5054
  note: plugin.description || "",
4839
5055
  ref: { kind: "plugin", value: plugin.name, label: plugin.name },
4840
5056
  }));
@@ -5140,6 +5356,7 @@ function paintMarket() {
5140
5356
  const hit = (entry) =>
5141
5357
  !query ||
5142
5358
  (entry.name || "").toLowerCase().includes(query) ||
5359
+ (entry.title || "").toLowerCase().includes(query) ||
5143
5360
  (entry.description || "").toLowerCase().includes(query) ||
5144
5361
  (entry.label || "").toLowerCase().includes(query);
5145
5362
  const installed = (
@@ -5256,7 +5473,9 @@ function installedRow(entry) {
5256
5473
  row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
5257
5474
 
5258
5475
  const naming = el("div", "mk-naming");
5259
- const heading = el("div", "mk-name", entry.name);
5476
+ // A plugin may carry a display title over its id; the id is still what a
5477
+ // reference resolves by, so it is the title that is painted and nothing else.
5478
+ const heading = el("div", "mk-name", entry.title || entry.name);
5260
5479
  /**
5261
5480
  * Where it came from, when that is not "this machine".
5262
5481
  *
@@ -5718,7 +5937,8 @@ function openCommands(command) {
5718
5937
  attachPicker.classList.add("open");
5719
5938
  const bar = attachPicker.getBoundingClientRect().top;
5720
5939
  const box = composerBox.getBoundingClientRect().top;
5721
- attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
5940
+ // On a phone the menu is positioned from the box itself by the stylesheet.
5941
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
5722
5942
  paintAttachMenu();
5723
5943
  }
5724
5944
 
@@ -5775,7 +5995,8 @@ function openMention(mention) {
5775
5995
  // the sentence it is going into.
5776
5996
  const bar = attachPicker.getBoundingClientRect().top;
5777
5997
  const box = composerBox.getBoundingClientRect().top;
5778
- attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
5998
+ // On a phone the menu is positioned from the box itself by the stylesheet.
5999
+ attachMenu.style.bottom = isPhone() ? "" : "calc(100% + " + Math.round(bar - box + 14) + "px)";
5779
6000
  paintAttachMenu();
5780
6001
  searchMentionFiles();
5781
6002
  }
@@ -5924,8 +6145,50 @@ stopBtn.addEventListener("click", () => void api("/interrupt", { session: active
5924
6145
  // almost every time, and a dialog in front of every new session charges the
5925
6146
  // common case for the rare one.
5926
6147
  document.getElementById("new").addEventListener("click", () => void switchTo({ action: "new" }));
6148
+ /**
6149
+ * The sidebar as a drawer, on a phone.
6150
+ *
6151
+ * Open from the burger, closed from the scrim, the mark in its own corner,
6152
+ * Escape, or by choosing anything in it: a drawer that stayed over the
6153
+ * transcript after a session was picked hid the very thing that was picked.
6154
+ */
6155
+ const sidebar = document.querySelector("body > aside");
6156
+ function setNavOpen(open) {
6157
+ sidebar.classList.toggle("open", open);
6158
+ document.body.classList.toggle("nav-open", open);
6159
+ }
6160
+ function closeNav() {
6161
+ if (sidebar.classList.contains("open")) setNavOpen(false);
6162
+ }
5927
6163
  document.getElementById("burger").addEventListener("click", () =>
5928
- document.querySelector("aside").classList.toggle("open"));
6164
+ setNavOpen(!sidebar.classList.contains("open")));
6165
+ document.getElementById("nav-close").addEventListener("click", () => setNavOpen(false));
6166
+ document.getElementById("scrim").addEventListener("click", () => setNavOpen(false));
6167
+ sidebar.addEventListener("click", (e) => {
6168
+ if (!isPhone() || e.target.closest(".more")) return;
6169
+ if (e.target.closest(".session, .nav-item, .project")) closeNav();
6170
+ });
6171
+ document.addEventListener("keydown", (e) => {
6172
+ if (e.key === "Escape" && sidebar.classList.contains("open")) closeNav();
6173
+ });
6174
+ document.getElementById("review-close").addEventListener("click", () =>
6175
+ review.classList.remove("open"));
6176
+
6177
+ // The keyboard takes the bottom of the screen and iOS does not shrink the
6178
+ // layout to match, so the composer would sit under it. The body follows the
6179
+ // visible viewport instead, on phones only.
6180
+ if (window.visualViewport) {
6181
+ const followKeyboard = () => {
6182
+ if (!isPhone()) {
6183
+ document.body.style.height = "";
6184
+ return;
6185
+ }
6186
+ document.body.style.height = Math.round(window.visualViewport.height) + "px";
6187
+ window.scrollTo(0, 0);
6188
+ };
6189
+ window.visualViewport.addEventListener("resize", followKeyboard);
6190
+ window.addEventListener("resize", followKeyboard);
6191
+ }
5929
6192
 
5930
6193
 
5931
6194
  // --- review pane (the Codex-shaped part) ----------------------------------
@@ -7498,6 +7761,31 @@ function discoverNow(providerId) {
7498
7761
  });
7499
7762
  }
7500
7763
 
7764
+ /** A plus in a ring: adding credits. Drawn like the sidebar's other line icons. */
7765
+ function iconTopUp() {
7766
+ const NS = "http://www.w3.org/2000/svg";
7767
+ const svg = document.createElementNS(NS, "svg");
7768
+ svg.setAttribute("class", "ico");
7769
+ svg.setAttribute("viewBox", "0 0 16 16");
7770
+ svg.setAttribute("fill", "none");
7771
+ svg.setAttribute("stroke", "currentColor");
7772
+ svg.setAttribute("stroke-width", "1.5");
7773
+ svg.setAttribute("stroke-linecap", "round");
7774
+ svg.setAttribute("stroke-linejoin", "round");
7775
+ svg.setAttribute("aria-hidden", "true");
7776
+ const ring = document.createElementNS(NS, "circle");
7777
+ ring.setAttribute("cx", "8");
7778
+ ring.setAttribute("cy", "8");
7779
+ ring.setAttribute("r", "6.67");
7780
+ svg.appendChild(ring);
7781
+ for (const d of ["M8 5.33v5.34", "M5.33 8h5.34"]) {
7782
+ const path = document.createElementNS(NS, "path");
7783
+ path.setAttribute("d", d);
7784
+ svg.appendChild(path);
7785
+ }
7786
+ return svg;
7787
+ }
7788
+
7501
7789
  /** A person, drawn to match the sidebar's other 16px line icons. */
7502
7790
  function iconPerson() {
7503
7791
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@@ -7535,6 +7823,15 @@ function iconPerson() {
7535
7823
  * and none of those can be hosted in a local window — they keep working here
7536
7824
  * precisely because this does not reimplement them.
7537
7825
  */
7826
+ /**
7827
+ * The teams the account is on, as the last account load found them.
7828
+ *
7829
+ * Kept here so the paywall can name a team by its id without a second
7830
+ * request: the id is what the billing answer carries, and the name is what
7831
+ * a person reads.
7832
+ */
7833
+ let knownTeams = [];
7834
+
7538
7835
  async function loadAccount() {
7539
7836
  const host = document.getElementById("account");
7540
7837
  if (!host) return;
@@ -7599,6 +7896,18 @@ async function loadAccount() {
7599
7896
  // Whether there is a shop at all. A self-hosted gateway sells nothing, and
7600
7897
  // an entry that opens a paywall with no plans in it is worse than no entry.
7601
7898
  let sellsCredits = false;
7899
+ // Whether the plan is live. This decides which billing action is primary in
7900
+ // the account card: Subscribe before a plan, Top up afterwards.
7901
+ let subscribed = false;
7902
+ // The team this machine bills to, once the gateway has said what it has.
7903
+ // A team the account was removed from comes back refused, and the line
7904
+ // says so rather than showing a number it could not read.
7905
+ let teamLine = null;
7906
+ knownTeams = Array.isArray(body.teams) ? body.teams : [];
7907
+ const teamNameOf = (id) => {
7908
+ const found = knownTeams.find((team) => team.id === id);
7909
+ return found ? found.name : t("account.teamUnnamed");
7910
+ };
7602
7911
  void getJson("/billing").then((state) => {
7603
7912
  if (!state || !state.signedIn || state.unavailable) return;
7604
7913
  sellsCredits = true;
@@ -7607,8 +7916,28 @@ async function loadAccount() {
7607
7916
  }
7608
7917
  const sub = state.subscription;
7609
7918
  plan = sub && sub.plan ? t("account.plan", { plan: sub.plan, status: sub.status }) : t("account.noPlan");
7919
+ subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
7920
+ if (state.team) {
7921
+ teamLine = state.team.refused
7922
+ ? t("account.teamRefused", { team: teamNameOf(state.team.id) })
7923
+ : t("account.team", {
7924
+ team: teamNameOf(state.team.id),
7925
+ credits: creditsOf(state.team.balanceMicro || 0),
7926
+ });
7927
+ }
7610
7928
  });
7611
7929
 
7930
+ // Switch whose credits the next call spends. Said out loud, because the
7931
+ // only other sign is a different number in this menu next time it opens.
7932
+ const billTo = (id) => {
7933
+ closeRowMenu();
7934
+ void api("/team", { team: id }).then((result) => {
7935
+ if (!result || result.error) return void addNotice((result && result.error) || "could not switch", "err");
7936
+ addNotice(id ? t("account.usingTeam", { team: teamNameOf(id) }) : t("account.usingOwn"));
7937
+ void loadAccount();
7938
+ });
7939
+ };
7940
+
7612
7941
  row.addEventListener("click", (e) => {
7613
7942
  e.stopPropagation();
7614
7943
  if (menuRow === row) return closeRowMenu();
@@ -7617,36 +7946,79 @@ async function loadAccount() {
7617
7946
  // dim lines the size of a footnote, above the things to click — which is
7618
7947
  // backwards: the balance is why somebody opened this menu, and the plan
7619
7948
  // is what decides which of the entries below is the one they want.
7620
- if (balance || plan) {
7949
+ if (balance || plan || teamLine) {
7621
7950
  const head = el("div", "menu-head");
7622
7951
  if (balance) head.appendChild(el("div", "menu-head-main", balance));
7623
7952
  if (plan) head.appendChild(el("div", "menu-head-sub", plan));
7624
- items.push(head);
7625
- }
7626
- // Buying, before the dashboard. Somebody who opened this menu after seeing
7627
- // "0 credits" two lines up wants the shop, and the dashboard is a longer
7628
- // way to the same place. It opens the paywall this app already has rather
7629
- // than a browser tab: the balance refreshes itself there, and the turn
7630
- // that could not be paid for is one click from being sent again.
7631
- if (sellsCredits) {
7632
- items.push(
7633
- menuItem(t("account.getCredits"), {
7634
- run: () => {
7953
+ // Under the person's own line: it is the second balance in play, and
7954
+ // the one the next call actually draws on while a team is chosen.
7955
+ if (teamLine) head.appendChild(el("div", "menu-head-sub", teamLine));
7956
+ if (sellsCredits) {
7957
+ const actions = el("div", "menu-billing-actions");
7958
+ if (!subscribed) {
7959
+ const subscribe = el("button", "menu-billing-action primary", t("paywall.subscribe"));
7960
+ subscribe.type = "button";
7961
+ subscribe.addEventListener("click", () => {
7635
7962
  closeRowMenu();
7636
7963
  openPaywall(true);
7637
- },
7638
- }),
7639
- );
7964
+ });
7965
+ actions.appendChild(subscribe);
7966
+ }
7967
+ const topUp = el("button", "menu-billing-action" + (subscribed ? " primary" : ""), t("paywall.topUp"));
7968
+ topUp.type = "button";
7969
+ topUp.disabled = !subscribed;
7970
+ if (!subscribed) topUp.title = t("paywall.needsPlan");
7971
+ topUp.addEventListener("click", () => {
7972
+ closeRowMenu();
7973
+ openPaywall(true);
7974
+ });
7975
+ actions.appendChild(topUp);
7976
+ head.appendChild(actions);
7977
+ }
7978
+ items.push(head);
7640
7979
  }
7980
+ // The dashboard straight under the balance it explains, and above the
7981
+ // workspace list, which is a choice rather than a destination.
7641
7982
  items.push(
7642
7983
  menuItem(t("account.dashboard"), {
7643
7984
  run: () => {
7644
7985
  closeRowMenu();
7645
- // window.open, so the desktop build hands this to the real browser
7646
- // rather than loading the site inside the window the agent runs in.
7647
7986
  if (body.site) window.open(body.site + "/" + LOCALE + "/dashboard", "_blank", "noopener");
7648
7987
  },
7649
7988
  }),
7989
+ );
7990
+ // Whose credits the next call spends: Personal, or a team the account is
7991
+ // on, the current one ticked the way the model menu ticks its choice.
7992
+ // Only once there is a team to choose, since a list of one is no choice.
7993
+ if (knownTeams.length) {
7994
+ items.push(el("div", "menu-head menu-section", t("account.workspace")));
7995
+ const choice = (label, id) => {
7996
+ const chosen = (body.team || null) === id;
7997
+ const item = el("button", "menu-item" + (chosen ? " on" : ""));
7998
+ item.type = "button";
7999
+ item.appendChild(el("span", "tick", "\u2713"));
8000
+ item.appendChild(el("span", "menu-label", label));
8001
+ item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
8002
+ return item;
8003
+ };
8004
+ items.push(choice(t("account.personal"), null));
8005
+ knownTeams.forEach((team) => items.push(choice(team.name, team.id)));
8006
+ }
8007
+ // The way to a workspace at all, for everyone signed in: Create a team for an
8008
+ // account on none, Manage workspaces once there is one. Both are the team page
8009
+ // on the site, in the real browser, where a team is started, its people
8010
+ // invited and its seats bought.
8011
+ if (body.site) {
8012
+ items.push(
8013
+ menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
8014
+ run: () => {
8015
+ closeRowMenu();
8016
+ window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
8017
+ },
8018
+ }),
8019
+ );
8020
+ }
8021
+ items.push(
7650
8022
  menuItem(t("account.download"), {
7651
8023
  run: () => {
7652
8024
  closeRowMenu();
@@ -7667,6 +8039,11 @@ async function loadAccount() {
7667
8039
  }),
7668
8040
  );
7669
8041
  openRowMenu(row, row, items);
8042
+ // As wide as the row it opens from, and placed again once it is: the
8043
+ // first placement measured the menu at its own width.
8044
+ rowMenu.classList.add("fit-anchor", "account-menu");
8045
+ rowMenu.style.width = row.getBoundingClientRect().width + "px";
8046
+ placeRowMenu(row);
7670
8047
  });
7671
8048
  host.appendChild(row);
7672
8049
  }
@@ -7953,6 +8330,48 @@ function openPaywall(buying) {
7953
8330
  return;
7954
8331
  }
7955
8332
 
8333
+ // A team is chosen, so the balance that ran out is the team's, and the
8334
+ // shop for it is on the site rather than here — only someone with billing
8335
+ // permission buys for the team, and they do it on the team page. What this screen
8336
+ // can offer is the team's number, who can fix it, and the way back to
8337
+ // spending one's own credits.
8338
+ if (state.team) {
8339
+ const teamName = (knownTeams.find((team) => team.id === state.team.id) || {}).name
8340
+ || t("account.teamUnnamed");
8341
+ if (state.team.refused) {
8342
+ body.appendChild(el("div", "cn-note", t("account.teamRefused", { team: teamName })));
8343
+ } else {
8344
+ const pooled = el("p", "pw-balance");
8345
+ pooled.appendChild(el("strong", null, creditsOf(state.team.balanceMicro || 0)));
8346
+ pooled.appendChild(el("span", "muted", t("paywall.teamUnit", { team: teamName })));
8347
+ body.appendChild(pooled);
8348
+ body.appendChild(
8349
+ el("div", "cn-note",
8350
+ t(state.team.billing ? "paywall.teamOwner" : "paywall.teamMember", { team: teamName })),
8351
+ );
8352
+ }
8353
+ const actions = el("div", "pw-plans");
8354
+ if (state.team.billing || state.team.refused) {
8355
+ const page = el("button", "pw-plan", t("paywall.teamPage"));
8356
+ page.type = "button";
8357
+ page.onclick = () => {
8358
+ window.open((site || "https://oharness.ai") + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
8359
+ };
8360
+ actions.appendChild(page);
8361
+ }
8362
+ const own = el("button", "pw-plan", t("account.useOwn"));
8363
+ own.type = "button";
8364
+ own.onclick = () => {
8365
+ void api("/team", { team: null }).then(() => {
8366
+ void loadAccount();
8367
+ void check();
8368
+ });
8369
+ };
8370
+ actions.appendChild(own);
8371
+ body.appendChild(actions);
8372
+ return;
8373
+ }
8374
+
7956
8375
  const sub = state.subscription;
7957
8376
  const subscribed = Boolean(sub && (sub.status === "active" || sub.status === "trialing"));
7958
8377
  body.appendChild(
@@ -7964,7 +8383,9 @@ function openPaywall(buying) {
7964
8383
  // checkout for it — so it is not a row. The account page button below is
7965
8384
  // where it lives.
7966
8385
  const packs = plans.filter((plan) => plan.kind === "topup");
7967
- const subs = plans.filter((plan) => plan.kind === "subscription");
8386
+ // The seat plan is a team's and is bought on the team page; on this
8387
+ // list it would be a rung this account cannot buy for itself.
8388
+ const subs = plans.filter((plan) => plan.kind === "subscription" && !plan.seat);
7968
8389
 
7969
8390
  if (!subs.length && !packs.length) {
7970
8391
  body.appendChild(el("div", "cn-note", t("paywall.notForSale")));
@@ -8122,10 +8543,14 @@ function openPaywall(buying) {
8122
8543
  }
8123
8544
  // Credits on the account mean the turn can be sent again, whether they
8124
8545
  // arrived a second ago or were there the whole time. Both are the same
8125
- // sentence to whoever is looking at this.
8126
- if (state.signedIn && Number(state.balanceMicro || 0) > 0) {
8546
+ // sentence to whoever is looking at this. With a team chosen it is the
8547
+ // team's number that decides: the person's own credits are not what
8548
+ // the next call draws on.
8549
+ const pooled = state.team && !state.team.refused ? state.team : undefined;
8550
+ const spendable = pooled ? Number(pooled.balanceMicro || 0) : Number(state.balanceMicro || 0);
8551
+ if (state.signedIn && spendable > 0) {
8127
8552
  closePaywall();
8128
- return void addNotice(t("paywall.ready", { credits: creditsOf(state.balanceMicro) }));
8553
+ return void addNotice(t("paywall.ready", { credits: creditsOf(spendable) }));
8129
8554
  }
8130
8555
  render(state);
8131
8556
  });
@@ -10136,7 +10561,7 @@ export const PAGE = `<!doctype html>
10136
10561
  <html lang="en">
10137
10562
  <head>
10138
10563
  <meta charset="utf-8">
10139
- <meta name="viewport" content="width=device-width,initial-scale=1">
10564
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
10140
10565
  <meta name="referrer" content="no-referrer">
10141
10566
  <title>OHarness</title>
10142
10567
  <link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON)}">
@@ -10155,6 +10580,7 @@ ${ANALYTICS_STUB}
10155
10580
  </svg>
10156
10581
  </span>
10157
10582
  <b class="wordmark">OHarness</b>
10583
+ <button id="nav-close" type="button" aria-label="Close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
10158
10584
  </header>
10159
10585
  <button id="project" class="project" type="button">
10160
10586
  <span class="avatar" id="project-avatar"></span>
@@ -10191,6 +10617,7 @@ ${ANALYTICS_STUB}
10191
10617
  model-provider setup — a place nobody goes to find their account. -->
10192
10618
  <div id="account"></div>
10193
10619
  </aside>
10620
+ <div id="scrim"></div>
10194
10621
 
10195
10622
  <main>
10196
10623
  <div id="top">
@@ -10289,6 +10716,7 @@ ${ANALYTICS_STUB}
10289
10716
  <div id="review-head">
10290
10717
  <span id="review-title" data-t="review.changes"></span>
10291
10718
  <span id="review-sum"></span>
10719
+ <button id="review-close" type="button" aria-label="Close"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
10292
10720
  </div>
10293
10721
  <div id="files"></div>
10294
10722
  <div id="diff"></div>