landfall 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -9,171 +9,179 @@
9
9
  :root {
10
10
  /* Three text sizes and three corner radii; headings set their own. */
11
11
  --fs-xs: 11.5px; --fs-sm: 12.5px; --fs: 13px;
12
- --r-sm: 6px; --r: 8px; --r-lg: 12px;
13
- --bg: #f6f7f9; --panel: #fff; --line: #e3e6eb; --line-strong: #c4c9d2; --text: #1b1f24; --dim: #5f6774; --faint: #6e7582;
14
- --accent: #1d4ed8; --accent-bg: #e8effd; --accent-solid: var(--accent); --bad-solid: var(--bad); --ok: #13733a; --ok-bg: #e5f5ea; --warn: #b45309; --warn-bg: #fdf3e2;
15
- --bad: #c0262d; --bad-bg: #fbe9ea; --term: #11151b; --term-text: #d7dce3; --shadow: 0 1px 2px rgb(0 0 0 / .05), 0 4px 16px rgb(0 0 0 / .06);
12
+ --r-sm: 7px; --r: 10px; --r-lg: 14px;
13
+ --bg: #fafafa; --panel: #fff; --hover: #f4f4f5; --line: #ececee; --line-strong: #d4d4d8; --text: #18181b; --dim: #5d5d66; --faint: #71717a;
14
+ --accent: #4f46e5; --accent-bg: #eef0fe; --accent-solid: #4f46e5; --bad-solid: #dc2626; --ok: #15803d; --ok-bg: #eaf7ee; --warn: #b45309; --warn-bg: #fdf4e3;
15
+ --bad: #c42323; --bad-bg: #fcecec; --term: #0f0f12; --term-text: #dcdce1;
16
+ --shadow: 0 1px 2px rgb(0 0 0 / .04); --shadow-lg: 0 10px 30px -8px rgb(0 0 0 / .14), 0 2px 6px rgb(0 0 0 / .05);
17
+ --ring: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
16
18
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
17
19
  color-scheme: light;
18
20
  }
19
21
  @media (prefers-color-scheme: dark) {
20
22
  :root {
21
- --bg: #0e1116; --panel: #161a21; --line: #262c36; --line-strong: #3a4250; --text: #e4e7ec; --dim: #8b93a1; --faint: #7d8594;
22
- --accent: #5b8cff; --accent-bg: #1a2540; --accent-solid: #3366dd; --bad-solid: #cc3333; --ok: #4ade80; --ok-bg: #12291b; --warn: #f5b34a; --warn-bg: #2d2412;
23
- --bad: #ff6b6b; --bad-bg: #331719; --term: #0a0d11; --term-text: #d7dce3; --shadow: 0 1px 2px rgb(0 0 0 / .3), 0 6px 20px rgb(0 0 0 / .35);
23
+ --bg: #0c0c0e; --panel: #141417; --hover: #1c1c20; --line: #232328; --line-strong: #34343b; --text: #ececef; --dim: #a1a1aa; --faint: #8b8b94;
24
+ --accent: #8b93ff; --accent-bg: #1d1d3a; --accent-solid: #5b54e8; --bad-solid: #d33a3a; --ok: #4ade80; --ok-bg: #122a1b; --warn: #f5b34a; --warn-bg: #2b2212;
25
+ --bad: #ff7b7b; --bad-bg: #321819; --term: #08080a; --term-text: #dcdce1;
26
+ --shadow: 0 1px 2px rgb(0 0 0 / .4); --shadow-lg: 0 16px 40px -8px rgb(0 0 0 / .6), 0 2px 6px rgb(0 0 0 / .4);
24
27
  color-scheme: dark;
25
28
  }
26
29
  }
27
30
  * { box-sizing: border-box; }
28
31
  html, body { margin: 0; height: 100%; }
29
- body { background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; }
32
+ body { background: var(--bg); color: var(--text); font: 14px/1.5 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
30
33
  a { color: var(--accent); text-decoration: none; }
31
- a:hover { text-decoration: underline; }
34
+ a:hover { text-decoration: underline; text-underline-offset: 2px; }
32
35
  code, .mono { font-family: var(--mono); font-size: var(--fs-sm); }
33
36
  button {
34
- font: inherit; font-size: var(--fs); border: 1px solid var(--line); background: var(--panel); color: var(--text);
35
- padding: 5px 11px; border-radius: var(--r-sm); cursor: pointer; white-space: nowrap;
37
+ font: inherit; font-size: var(--fs); font-weight: 500; border: 1px solid var(--line-strong); background: var(--panel); color: var(--text);
38
+ padding: 5px 12px; border-radius: var(--r-sm); cursor: pointer; white-space: nowrap; box-shadow: var(--shadow);
39
+ transition: background-color .12s, border-color .12s, color .12s, box-shadow .12s;
36
40
  }
37
- button:hover { border-color: var(--faint); }
38
- button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
39
- button.primary:hover { filter: brightness(1.08); }
41
+ button:hover { background: var(--hover); }
42
+ button:focus-visible, a:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
40
43
  button:disabled { opacity: .5; cursor: default; }
41
44
  button.primary { background: var(--accent-solid); border-color: var(--accent-solid); color: #fff; }
45
+ button.primary:hover, button.danger.solid:hover { filter: brightness(1.1); }
42
46
  button.danger { color: var(--bad); }
47
+ button.danger:hover { background: var(--bad-bg); }
43
48
  button.danger.solid { background: var(--bad-solid); border-color: var(--bad-solid); color: #fff; }
44
- button.danger.solid:hover { filter: brightness(1.08); }
45
- button.ghost { border-color: transparent; background: transparent; color: var(--dim); }
46
- button.ghost:hover { color: var(--text); background: var(--bg); }
47
- button.sm { padding: 2px 8px; font-size: var(--fs-sm); }
49
+ button.ghost { border-color: transparent; background: transparent; color: var(--dim); box-shadow: none; }
50
+ button.ghost:hover { color: var(--text); background: var(--hover); }
51
+ button.sm { padding: 2px 9px; font-size: var(--fs-sm); }
48
52
  input, select, textarea {
49
- font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--r-sm); padding: 6px 9px; width: 100%;
53
+ font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--r-sm); padding: 7px 10px; width: 100%;
54
+ transition: border-color .12s, box-shadow .12s;
50
55
  }
51
56
  input.secret { -webkit-text-security: disc; text-security: disc; }
52
- input:focus, select:focus, textarea:focus { outline: 3px solid color-mix(in srgb, var(--accent) 30%, transparent); outline-offset: 0; border-color: var(--accent); }
57
+ input:focus, select:focus, textarea:focus { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
53
58
  textarea { font-family: var(--mono); font-size: var(--fs-sm); min-height: 90px; }
54
59
 
55
- .app { display: grid; grid-template-columns: 250px minmax(0, 1fr); grid-template-rows: 52px 1fr; height: 100vh; }
56
- .app.with-job { grid-template-columns: 250px minmax(0, 1fr) clamp(360px, 32vw, 520px); }
57
- header { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
58
- .brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; letter-spacing: -.01em; color: var(--text); }
60
+ .app { display: grid; grid-template-columns: 248px minmax(0, 1fr); grid-template-rows: 56px minmax(0, 1fr); height: 100vh; }
61
+ .app.with-job { grid-template-columns: 248px minmax(0, 1fr) clamp(360px, 32vw, 520px); }
62
+ header { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
63
+ .brand { font-weight: 650; font-size: 15px; display: flex; align-items: center; gap: 9px; letter-spacing: -.015em; color: var(--text); }
59
64
  .brand:hover { text-decoration: none; }
60
- .brand svg { width: 22px; height: 22px; flex: none; }
61
- .brand small { font-weight: 400; color: var(--faint); font-size: var(--fs-sm); letter-spacing: 0; }
65
+ .brand svg { width: 24px; height: 24px; flex: none; }
66
+ .brand small { font-weight: 500; color: var(--faint); font-size: var(--fs-xs); letter-spacing: 0; padding: 1px 7px; border-radius: 99px; background: var(--hover); }
62
67
  .project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: var(--fs); min-width: 0; }
63
- .project .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36vw; }
64
- nav.servers { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; display: flex; flex-direction: column; }
65
- .nav-top { position: sticky; top: 0; z-index: 1; background: var(--panel); display: flex; gap: 6px; padding: 10px 10px 6px; }
66
- .nav-top input { padding: 5px 9px; font-size: var(--fs); }
67
- .nav-top button { flex: none; }
68
- #navlist { padding: 0 8px 12px; }
69
- nav h3 { margin: 10px 8px 4px; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; display: flex; justify-content: space-between; }
70
- nav h3 span { font-weight: 500; letter-spacing: 0; }
71
- .srv { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 6px 10px; border-radius: var(--r-sm); }
72
- .srv:hover { background: var(--bg); border-color: transparent; }
73
- .srv.on { background: var(--accent-bg); }
74
- .srv.on .n { color: var(--accent); }
75
- .srv.on .t { color: var(--text); }
76
- .srv .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
77
- .srv .t { color: var(--dim); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68
+ .project .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36vw; font-family: var(--mono); font-size: var(--fs-sm); }
69
+ nav.servers { border-right: 1px solid var(--line); background: var(--bg); overflow: auto; display: flex; flex-direction: column; }
70
+ .nav-top { position: sticky; top: 0; z-index: 1; background: var(--bg); display: flex; gap: 6px; padding: 14px 12px 8px; }
71
+ .nav-top input { padding: 6px 10px; font-size: var(--fs); }
72
+ .nav-top button { flex: none; padding-inline: 10px; }
73
+ #navlist { padding: 0 8px 16px; }
74
+ nav h3 { margin: 14px 10px 4px; font-size: var(--fs-xs); color: var(--faint); font-weight: 500; display: flex; justify-content: space-between; }
75
+ nav h3 span { font-weight: 500; }
76
+ .srv { display: block; width: 100%; text-align: left; border: 0; background: none; box-shadow: none; padding: 6px 10px; border-radius: var(--r-sm); }
77
+ .srv:hover { background: var(--hover); }
78
+ .srv.on { background: var(--panel); box-shadow: 0 0 0 1px var(--line), var(--shadow); }
79
+ .srv.on .n { color: var(--text); }
80
+ .srv.on .t { color: var(--dim); }
81
+ .srv .n { font-weight: 550; display: flex; align-items: center; gap: 6px; }
82
+ .srv .t { color: var(--faint); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
83
  .nav-none { color: var(--faint); font-size: var(--fs-sm); padding: 6px 10px; }
79
84
  .srv .t .live { color: var(--ok); }
80
85
  /* The open server's sections, under it in the list. */
81
- .sections { display: grid; gap: 1px; margin: 2px 0 8px 14px; padding-left: 6px; border-left: 1px solid var(--line); }
82
- .sections button { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: 0; background: none; padding: 4px 10px; color: var(--dim); }
83
- .sections button:hover { background: var(--bg); color: var(--text); }
84
- .sections button.on { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
86
+ .sections { display: grid; gap: 1px; margin: 4px 0 10px 12px; padding-left: 8px; border-left: 1px solid var(--line-strong); }
87
+ .sections button { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: 0; background: none; box-shadow: none; padding: 5px 10px; color: var(--dim); font-weight: 450; }
88
+ .sections button:hover { background: var(--hover); color: var(--text); }
89
+ .sections button.on { background: var(--accent-bg); color: var(--accent); font-weight: 550; }
85
90
  .sections .badge { margin-left: auto; }
86
- .head h1 .crumb { font-weight: 400; color: var(--dim); }
91
+ .head h1 .crumb { font-weight: 400; color: var(--faint); }
87
92
  .tunnel-url { font-family: var(--mono); font-size: var(--fs-sm); }
88
93
  .tunnel-lan::before { content: "· LAN "; color: var(--faint); font-size: var(--fs-sm); }
89
94
  .star { color: var(--warn); font-size: var(--fs-xs); }
90
- main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
95
+ main { overflow: auto; padding: 28px 32px 48px; min-width: 0; container-type: inline-size; }
91
96
  .show-sm { display: none !important; }
92
97
  /* By main's own width, so an open job panel squeezes the tables like a small screen does. */
93
98
  @container (max-width: 720px) {
94
- td, th { padding: 8px; }
99
+ td, th { padding: 9px 8px; }
95
100
  .hide-sm { display: none; }
96
101
  .show-sm { display: flex !important; }
97
102
  }
98
- .empty { color: var(--dim); text-align: center; padding: 60px 20px; }
99
- .empty h2 { color: var(--text); font-size: 18px; margin: 0 0 6px; }
100
- .head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 20px; margin-bottom: 16px; }
103
+ .empty { color: var(--dim); text-align: center; padding: 72px 20px; }
104
+ .empty h2 { color: var(--text); font-size: 18px; font-weight: 600; letter-spacing: -.01em; margin: 0 0 6px; }
105
+ .head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 20px; margin-bottom: 22px; }
101
106
  .head > div:first-child { flex: 1 1 320px; min-width: 0; }
102
- .head h1 { margin: 0; font-size: 22px; }
103
- .head .sub { color: var(--dim); font-size: var(--fs); margin-top: 2px; }
107
+ .head h1 { margin: 0; font-size: 24px; font-weight: 650; letter-spacing: -.02em; line-height: 1.25; }
108
+ .head .sub { color: var(--dim); font-size: var(--fs); margin-top: 4px; }
104
109
  .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
105
110
  /* Phones only: elsewhere the sections sit under the server in the list. */
106
- .tabs { display: none; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
111
+ .tabs { display: none; gap: 4px; padding: 4px; background: var(--hover); border-radius: var(--r); margin-bottom: 18px; overflow-x: auto; scrollbar-width: none; }
107
112
  .tabs::-webkit-scrollbar { display: none; }
108
- .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); flex: none; }
109
- .tabs button:hover { color: var(--text); }
110
- .tabs button.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
111
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); }
112
- .card + .card { margin-top: 16px; }
113
- .card > h2 { margin: 0; padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
113
+ .tabs button { border: 0; border-radius: var(--r-sm); background: none; box-shadow: none; padding: 6px 12px; color: var(--dim); flex: none; }
114
+ .tabs button:hover { color: var(--text); background: none; }
115
+ .tabs button.on { color: var(--text); background: var(--panel); box-shadow: var(--shadow), 0 0 0 1px var(--line); }
116
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: clip; }
117
+ .card + .card { margin-top: 20px; }
118
+ .card > h2 { margin: 0; padding: 14px 18px; font-size: 14px; font-weight: 600; border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
114
119
  .card > h2 .actions { font-weight: 400; }
115
120
  .card > h2 .sub { font-weight: 400; color: var(--dim); }
116
- .card > .lede { padding: 10px 16px; color: var(--dim); font-size: var(--fs); border-bottom: 1px solid var(--line); }
121
+ .card > .lede { padding: 10px 18px; color: var(--dim); font-size: var(--fs); border-bottom: 1px solid var(--line); }
117
122
 
118
123
  /* "⋯" menus: one floating list at a time, placed by openMenu(). */
119
- button.more { padding-inline: 7px; letter-spacing: .05em; }
120
- .menu { position: fixed; z-index: 30; min-width: 180px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 4px; display: grid; }
121
- .menu button { border: 0; background: none; text-align: left; padding: 6px 10px; border-radius: var(--r-sm); display: flex; justify-content: space-between; gap: 16px; }
122
- .menu button:hover { background: var(--bg); }
123
- .menu button:focus-visible { background: var(--accent-bg); outline: 2px solid var(--accent); outline-offset: -2px; }
124
+ button.more { padding-inline: 8px; letter-spacing: .05em; }
125
+ .menu { position: fixed; z-index: 30; min-width: 190px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); box-shadow: var(--shadow-lg); padding: 5px; display: grid; animation: pop .12s ease-out; }
126
+ .menu button { border: 0; background: none; box-shadow: none; font-weight: 450; text-align: left; padding: 6px 10px; border-radius: var(--r-sm); display: flex; justify-content: space-between; gap: 16px; }
127
+ .menu button:hover { background: var(--hover); }
128
+ .menu button:focus-visible { background: var(--accent-bg); box-shadow: none; }
124
129
  .menu button.danger:hover { background: var(--bad-bg); }
125
130
  .menu button .k { color: var(--faint); font-family: var(--mono); font-size: var(--fs-xs); }
126
131
  .menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
127
-
128
- .home { max-width: 860px; margin: 0 auto; padding-top: 20px; }
129
- .home h1 { margin: 0 0 4px; font-size: 22px; }
130
- .home > p { color: var(--dim); margin: 0 0 20px; }
131
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
132
- .tile { display: block; text-align: left; padding: 14px 16px; border-radius: var(--r-lg); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); width: 100%; }
133
- .tile:hover { border-color: var(--accent); }
132
+ @keyframes pop { from { opacity: 0; transform: translateY(-3px) scale(.98); } }
133
+
134
+ .home { max-width: 880px; margin: 0 auto; padding-top: 24px; }
135
+ .home h1 { margin: 0 0 6px; font-size: 28px; font-weight: 650; letter-spacing: -.025em; }
136
+ .home > p { color: var(--dim); margin: 0 0 24px; }
137
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
138
+ .tile { display: block; text-align: left; padding: 16px 18px; border-radius: var(--r-lg); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); width: 100%; transition: border-color .12s, box-shadow .15s, transform .15s; }
139
+ .tile:hover { background: var(--panel); border-color: var(--line-strong); box-shadow: var(--shadow-lg); transform: translateY(-1px); }
134
140
  .tile .n { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; }
135
- .tile .t { color: var(--dim); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
136
- .tile.add { border-style: dashed; box-shadow: none; color: var(--dim); display: grid; place-items: center; min-height: 64px; }
137
- .cli { margin-top: 28px; padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel); font-size: var(--fs); color: var(--dim); }
138
- .cli pre { margin: 8px 0 0; font: 12.5px/1.6 var(--mono); color: var(--text); white-space: pre-wrap; }
141
+ .tile .t { color: var(--faint); font-size: var(--fs-sm); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
142
+ .tile.add { border: 1.5px dashed var(--line-strong); background: none; box-shadow: none; color: var(--dim); display: grid; place-items: center; min-height: 68px; }
143
+ .tile.add:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); transform: none; box-shadow: none; }
144
+ .cli { margin-top: 32px; padding: 16px 18px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--hover); font-size: var(--fs); color: var(--dim); }
145
+ .cli pre { margin: 10px 0 0; font: 12.5px/1.7 var(--mono); color: var(--text); white-space: pre-wrap; }
139
146
  .cli pre span { color: var(--faint); }
140
147
 
141
- .drow { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
148
+ .drow { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 16px; padding: 12px 0; border-bottom: 1px solid var(--line); }
142
149
  .drow:first-child { padding-top: 0; }
150
+ .drow:last-child { border-bottom: 0; padding-bottom: 0; }
143
151
  .drow > div:first-child { flex: 1 1 260px; min-width: 0; }
144
152
  .drow .actions { flex: none; }
145
153
  .site-name { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
146
154
  td .chips.show-sm { margin-top: 4px; }
147
- .card .body { padding: 12px 16px; }
155
+ .card .body { padding: 16px 18px; }
148
156
  table { border-collapse: collapse; width: 100%; }
149
- th { text-align: left; font-weight: 600; font-size: var(--fs-xs); color: var(--faint); text-transform: uppercase; letter-spacing: .04em; padding: 8px 12px; border-bottom: 1px solid var(--line); }
150
- td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
157
+ th { text-align: left; font-weight: 500; font-size: var(--fs-sm); color: var(--faint); padding: 9px 14px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--hover) 55%, transparent); }
158
+ td { padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
151
159
  tr:last-child td { border-bottom: 0; }
152
160
  tr.click { cursor: pointer; }
153
- tr.click:hover td { background: var(--bg); }
161
+ tr.click:hover td { background: var(--hover); }
154
162
  td.acts { text-align: right; white-space: nowrap; }
155
163
  .nowrap { white-space: nowrap; }
156
164
  td.acts button { margin-left: 4px; }
157
165
  .scroll { overflow-x: auto; }
158
- .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 6px; vertical-align: 1px; }
159
- .dot.up { background: var(--ok); } .dot.down { background: var(--bad); } .dot.warn { background: var(--warn); }
160
- .badge { display: inline-block; font-size: var(--fs-xs); padding: 1px 7px; border-radius: 99px; background: var(--bg); color: var(--dim); border: 1px solid var(--line); }
166
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 7px; vertical-align: 1px; }
167
+ .dot.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); } .dot.down { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-bg); } .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-bg); }
168
+ .badge { display: inline-block; font-size: var(--fs-xs); font-weight: 500; line-height: 1.6; padding: 0 8px; border-radius: 99px; background: var(--hover); color: var(--dim); border: 1px solid var(--line); }
161
169
  .badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
162
170
  .badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
163
171
  .badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
164
172
  .badge.accent { background: var(--accent-bg); color: var(--accent); border-color: transparent; }
165
173
  .dim { color: var(--dim); } .faint { color: var(--faint); }
166
- .note { padding: 10px 14px; border-radius: var(--r); background: var(--warn-bg); color: var(--warn); margin-bottom: 14px; }
174
+ .note { padding: 11px 14px; border-radius: var(--r); background: var(--warn-bg); color: var(--warn); margin-bottom: 16px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
167
175
  .note.bad { background: var(--bad-bg); color: var(--bad); }
168
176
  .note.info { background: var(--accent-bg); color: var(--accent); }
169
- .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: var(--fs); margin: 0; }
170
- .kv dt { color: var(--dim); } .kv dd { margin: 0; }
171
- pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px 12px; border-radius: var(--r-sm); overflow-x: auto; font-size: var(--fs-sm); margin: 6px 0 0; }
172
- .detail td { background: var(--bg); }
177
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; font-size: var(--fs); margin: 0; }
178
+ .kv dt { color: var(--faint); } .kv dd { margin: 0; }
179
+ pre.block { background: var(--hover); border: 1px solid var(--line); padding: 10px 12px; border-radius: var(--r-sm); overflow-x: auto; font-size: var(--fs-sm); margin: 6px 0 0; }
180
+ .detail td { background: color-mix(in srgb, var(--hover) 60%, transparent); }
173
181
  .releases, .services { margin: 0; padding: 0; list-style: none; }
174
- .services li { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
175
- .releases li { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
176
- .run { padding: 12px 16px; border-bottom: 1px solid var(--line); }
182
+ .services li { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
183
+ .releases li { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
184
+ .run { padding: 14px 18px; border-bottom: 1px solid var(--line); }
177
185
  .run:last-child { border-bottom: 0; }
178
186
  .run .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
179
187
  .run .cmd { font-family: var(--mono); font-weight: 600; font-size: var(--fs); }
@@ -184,49 +192,50 @@ pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px
184
192
  .steps li.undone .what { text-decoration: line-through; }
185
193
  .steps li button { opacity: 0; }
186
194
  .steps li:hover button, .steps li:focus-within button { opacity: 1; }
187
- .form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; }
195
+ .form-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
188
196
  .form-row label { flex: 1 1 140px; }
189
- label { display: block; font-size: var(--fs-sm); color: var(--dim); }
190
- label > input, label > select, label > textarea { margin-top: 4px; }
191
- .check { display: flex; gap: 8px; align-items: flex-start; color: var(--text); font-size: var(--fs); }
192
- .check input { width: auto; margin-top: 3px; }
193
- .hint { font-size: var(--fs-sm); color: var(--faint); margin-top: 3px; }
194
- .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; }
197
+ label { display: block; font-size: var(--fs-sm); font-weight: 500; color: var(--dim); }
198
+ label > input, label > select, label > textarea { margin-top: 5px; font-weight: 400; }
199
+ .check { display: flex; gap: 8px; align-items: flex-start; color: var(--text); font-size: var(--fs); font-weight: 400; }
200
+ .check input { width: auto; margin-top: 3px; accent-color: var(--accent-solid); }
201
+ .hint { font-size: var(--fs-sm); font-weight: 400; color: var(--faint); margin-top: 4px; }
202
+ .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; }
195
203
  @keyframes spin { to { transform: rotate(360deg); } }
196
- @media (prefers-reduced-motion: reduce) { .spin { animation-duration: 3s; } }
197
- .loading { color: var(--dim); padding: 30px 0; text-align: center; }
204
+ @media (prefers-reduced-motion: reduce) { .spin { animation-duration: 3s; } .menu, dialog[open] { animation: none; } .tile:hover { transform: none; } }
205
+ .loading { color: var(--dim); padding: 40px 0; text-align: center; }
198
206
 
199
207
  [hidden] { display: none !important; }
200
208
  aside.job { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; min-width: 0; }
201
- .job-head { padding: 10px 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
202
- .job-head .title { font-family: var(--mono); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
203
- .job-out { flex: 1; overflow: auto; background: var(--term); color: var(--term-text); font: 12.5px/1.5 var(--mono); padding: 10px 12px; white-space: pre-wrap; word-break: break-word; }
204
- .job-out .c-cyan { color: #67d3e8; } .job-out .c-green { color: #6ee7a0; } .job-out .c-yellow { color: #f5c76a; } .job-out .c-red { color: #ff8a8a; } .job-out .c-dim { color: #7b8494; }
205
- .job-foot { padding: 10px 12px; border-top: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
209
+ .job-head { padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; min-height: 48px; }
210
+ .job-head .title { font-family: var(--mono); font-size: var(--fs-sm); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
211
+ .job-out { flex: 1; overflow: auto; background: var(--term); color: var(--term-text); font: 12.5px/1.6 var(--mono); padding: 12px 14px; white-space: pre-wrap; word-break: break-word; }
212
+ .job-out .c-cyan { color: #67d3e8; } .job-out .c-green { color: #6ee7a0; } .job-out .c-yellow { color: #f5c76a; } .job-out .c-red { color: #ff8a8a; } .job-out .c-dim { color: #7b7b86; }
213
+ .job-foot { padding: 10px 14px; border-top: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
206
214
  .job-foot .status { margin-right: auto; font-size: var(--fs); }
207
- .joblist { border-top: 1px solid var(--line); max-height: 28%; overflow: auto; }
208
- .joblist button { display: flex; width: 100%; gap: 8px; border: 0; border-radius: 0; text-align: left; background: none; padding: 6px 12px; font-family: var(--mono); font-size: var(--fs-sm); color: var(--dim); }
215
+ .joblist { border-top: 1px solid var(--line); max-height: 28%; overflow: auto; padding: 4px; }
216
+ .joblist button { display: flex; width: 100%; gap: 8px; border: 0; border-radius: var(--r-sm); box-shadow: none; text-align: left; background: none; padding: 6px 10px; font-family: var(--mono); font-size: var(--fs-sm); font-weight: 400; color: var(--dim); }
209
217
  .joblist button span:last-child { overflow: hidden; text-overflow: ellipsis; }
210
- .joblist button.on { background: var(--accent-bg); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
218
+ .joblist button.on { background: var(--accent-bg); color: var(--text); }
211
219
  .joblist .ok { color: var(--ok); } .joblist .bad { color: var(--bad); }
212
220
 
213
- dialog { border: 1px solid var(--line); border-radius: var(--r-lg); padding: 0; background: var(--panel); color: var(--text); box-shadow: var(--shadow); width: min(520px, calc(100vw - 32px)); }
214
- dialog::backdrop { background: rgb(0 0 0 / .35); }
215
- dialog form { padding: 18px 20px; display: grid; gap: 12px; }
216
- dialog h2 { margin: 0; font-size: 16px; }
221
+ dialog { border: 1px solid var(--line); border-radius: var(--r-lg); padding: 0; background: var(--panel); color: var(--text); box-shadow: var(--shadow-lg); width: min(520px, calc(100vw - 32px)); }
222
+ dialog[open] { animation: pop .15s ease-out; }
223
+ dialog::backdrop { background: rgb(9 9 11 / .4); backdrop-filter: blur(2px); }
224
+ dialog form { padding: 22px 24px; display: grid; gap: 14px; }
225
+ dialog h2 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
217
226
  dialog .text { color: var(--dim); white-space: pre-wrap; font-size: var(--fs); }
218
- dialog .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
219
- fieldset { border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
227
+ dialog .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
228
+ fieldset { border: 1px solid var(--line); border-radius: var(--r); padding: 12px 14px; margin: 0; display: grid; gap: 10px; }
220
229
  legend { font-size: var(--fs-sm); font-weight: 600; padding: 0 4px; }
221
- .toast { position: fixed; inset: auto auto 16px 50%; transform: translateX(-50%); margin: 0; border: 0; background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: var(--r); font-size: var(--fs); max-width: min(560px, calc(100vw - 32px)); }
222
- .toast button { margin-left: 10px; background: none; border-color: color-mix(in srgb, var(--bg) 40%, transparent); color: inherit; }
230
+ .toast { position: fixed; inset: auto auto 20px 50%; transform: translateX(-50%); margin: 0; border: 0; background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: var(--r); box-shadow: var(--shadow-lg); font-size: var(--fs); font-weight: 500; max-width: min(560px, calc(100vw - 32px)); }
231
+ .toast button { margin-left: 10px; background: none; box-shadow: none; border-color: color-mix(in srgb, var(--bg) 40%, transparent); color: inherit; }
223
232
  .sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
224
- .row-toggle { border: 0; background: none; padding: 2px 4px; margin-left: -4px; display: inline-flex; align-items: center; gap: 4px; color: inherit; font-weight: 600; }
225
- .row-toggle:hover { background: none; text-decoration: underline; }
233
+ .row-toggle { border: 0; background: none; box-shadow: none; padding: 2px 4px; margin-left: -4px; display: inline-flex; align-items: center; gap: 4px; color: inherit; font-weight: 600; }
234
+ .row-toggle:hover { background: none; text-decoration: underline; text-underline-offset: 2px; }
226
235
  .jobs-btn .spin { width: 10px; height: 10px; margin-right: 4px; }
227
236
  .dlg-err { margin: 0; }
228
237
  .server-pick { display: none; }
229
- .facts { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--dim); font-size: var(--fs-sm); margin-top: 4px; }
238
+ .facts { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--faint); font-size: var(--fs-sm); margin-top: 6px; }
230
239
  .facts b { color: var(--text); font-weight: 500; }
231
240
  .chips { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
232
241
  .chip { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
@@ -236,14 +245,14 @@ legend { font-size: var(--fs-sm); font-weight: 600; padding: 0 4px; }
236
245
  .lock { font-size: var(--fs-xs); margin-left: 4px; }
237
246
  dialog.wide { width: min(680px, calc(100vw - 32px)); }
238
247
  .list { display: grid; border: 1px solid var(--line); border-radius: var(--r); max-height: 260px; overflow: auto; }
239
- .list .row { display: flex; gap: 10px; align-items: baseline; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); text-align: left; background: none; padding: 6px 10px; min-width: 0; }
248
+ .list .row { display: flex; gap: 10px; align-items: baseline; border: 0; border-radius: 0; box-shadow: none; font-weight: 400; border-bottom: 1px solid var(--line); text-align: left; background: none; padding: 7px 12px; min-width: 0; }
240
249
  .list .row:last-child { border-bottom: 0; }
241
- .list .row:hover { background: var(--bg); }
250
+ .list .row:hover { background: var(--hover); }
242
251
  .list .row .mono { margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
243
252
  .crumbs { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; font-size: var(--fs-sm); }
244
- .crumbs button { padding: 1px 4px; }
245
- .detected { font-size: var(--fs); padding: 8px 12px; border-radius: var(--r); background: var(--bg); }
246
- .drop { border: 1.5px dashed var(--line); border-radius: var(--r); padding: 12px; text-align: center; color: var(--dim); font-size: var(--fs); }
253
+ .crumbs button { padding: 1px 6px; }
254
+ .detected { font-size: var(--fs); padding: 10px 12px; border-radius: var(--r); background: var(--hover); }
255
+ .drop { border: 1.5px dashed var(--line-strong); border-radius: var(--r); padding: 14px; text-align: center; color: var(--dim); font-size: var(--fs); }
247
256
  .drop label { display: inline; color: var(--accent); cursor: pointer; font-size: var(--fs); }
248
257
  body.dragging::after {
249
258
  content: "Drop a folder or .zip to deploy it"; position: fixed; inset: 12px; z-index: 20; display: grid; place-items: center;
@@ -252,19 +261,22 @@ body.dragging::after {
252
261
 
253
262
  @media (max-width: 1100px) {
254
263
  .app.with-job { grid-template-columns: 220px minmax(0, 1fr); }
255
- aside.job { position: fixed; inset: 52px 0 0 auto; width: min(560px, 100vw); z-index: 5; box-shadow: var(--shadow); }
264
+ aside.job { position: fixed; inset: 56px 0 0 auto; width: min(560px, 100vw); z-index: 5; box-shadow: var(--shadow-lg); }
256
265
  }
257
266
  @media (max-width: 760px) {
258
- .app, .app.with-job { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px auto 1fr; }
259
- header { padding: 0 12px; gap: 8px; }
267
+ .app, .app.with-job { grid-template-columns: minmax(0, 1fr); grid-template-rows: 56px auto minmax(0, 1fr); }
268
+ header { padding: 0 14px; gap: 8px; }
260
269
  .brand small { display: none; }
261
- .project button { padding: 5px 9px; font-size: var(--fs); white-space: nowrap; }
270
+ .project button { padding: 5px 10px; font-size: var(--fs); white-space: nowrap; }
262
271
  nav.servers { display: none; }
263
- .server-pick { display: block; padding: 10px 16px 0; }
264
- .tabs { display: flex; position: sticky; top: -14px; z-index: 2; background: var(--bg); }
265
- main { padding: 14px 16px 40px; }
272
+ .server-pick { display: block; padding: 12px 16px 0; }
273
+ .tabs { display: flex; position: sticky; top: -14px; z-index: 2; }
274
+ main { padding: 16px 16px 40px; }
275
+ .home h1 { font-size: 24px; }
266
276
  .project .path { display: none; }
267
- aside.job { inset: 52px 0 0 0; width: 100vw; }
277
+ aside.job { inset: 56px 0 0 0; width: 100vw; }
278
+ td.acts { white-space: normal; }
279
+ td.acts button { margin: 2px 0 2px 4px; }
268
280
  }
269
281
  </style>
270
282
  </head>
@@ -506,7 +518,10 @@ async function refreshState() {
506
518
  async function run(argv, o = {}) {
507
519
  let info;
508
520
  try {
509
- info = await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
521
+ // A retry runs the job's own argv on the server: the page only has it with passwords masked.
522
+ info = o.retry
523
+ ? await http(`/api/jobs/${encodeURIComponent(o.retry)}/force`, {})
524
+ : await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
510
525
  } catch (e) {
511
526
  return toast(e.message);
512
527
  }
@@ -522,7 +537,8 @@ function stream(j) {
522
537
  const es = new EventSource(`/api/jobs/${j.info.id}/events?from=${j.lines.length}`);
523
538
  j.es = es;
524
539
  es.onmessage = (e) => {
525
- const { from, lines } = JSON.parse(e.data);
540
+ const { from, lines, report } = JSON.parse(e.data);
541
+ if (report) j.report = report;
526
542
  j.lines.length = Math.min(j.lines.length, from);
527
543
  j.lines.push(...lines);
528
544
  if (S.job === j.info.id) appendOutput(j);
@@ -532,6 +548,7 @@ function stream(j) {
532
548
  es.addEventListener("done", (e) => {
533
549
  es.close();
534
550
  j.info = JSON.parse(e.data);
551
+ j.report = j.info.report;
535
552
  finished(j);
536
553
  });
537
554
  // Dropped connection while it still runs: pick up where it left off.
@@ -546,12 +563,12 @@ function finished(j) {
546
563
  const failed = info.code !== 0 && info.code !== null;
547
564
  j.actions = (j.after && j.after(j)) || [];
548
565
  // Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
549
- if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
550
- const why = j.lines.map((l) => l.replace(ANSI, "").trim()).filter(Boolean).slice(-4).join("\n");
566
+ const why = rep(j).force;
567
+ if (failed && !info.argv.includes("--force") && why) {
551
568
  j.actions.push({
552
569
  label: "Do it anyway (--force)…",
553
570
  cls: "danger",
554
- run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server }) }),
571
+ run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server, retry: info.id }) }),
555
572
  });
556
573
  }
557
574
  if (info.undoable && info.server && info.code !== null) {
@@ -583,9 +600,11 @@ const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === und
583
600
  const isTunnel = (info) => info.argv[0] === "tunnel" || (info.argv[0] === "traefik" && info.argv[1] === "dashboard");
584
601
  /** Tunnels the UI opened to server n that are still open. */
585
602
  const openTunnels = (n) => runningJobs().filter((j) => j.info.server === n && isTunnel(j.info));
586
- const tunnelUrl = (j) => j.lines.map((l) => l.replace(ANSI, "").match(/\b[a-z][a-z0-9+.-]*:\/\/localhost:\d+\S*/)?.[0]).find(Boolean);
603
+ /** What the job reported through the CLI's report(): never parsed from its text output. */
604
+ const rep = (j) => j.report ?? j.info.report ?? {};
605
+ const tunnelUrl = (j) => rep(j).url;
587
606
  /** With --lan: the addresses other devices on the network open it at. */
588
- const lanUrls = (j) => j.lines.map((l) => l.replace(ANSI, "")).find((l) => /on your network:/.test(l))?.match(/\b[a-z][a-z0-9+.-]*:\/\/\S+/g) ?? [];
607
+ const lanUrls = (j) => rep(j).lan ?? [];
589
608
  const closeTunnel = (j) => http(`/api/jobs/${j.info.id}/stop`, {}).catch((e) => toast(e.message));
590
609
  /** argv plus --port, --local and --lan, picked in a dialog. ports: the ones the container listens on, if known. */
591
610
  function tunnelOptions(n, argv, ports = []) {
@@ -615,7 +634,13 @@ function tunnelOptions(n, argv, ports = []) {
615
634
  function tunnelBtn(n, argv, label = "Open tunnel", ports = []) {
616
635
  // Opened with options, the job's argv goes on after argv.
617
636
  const j = openTunnels(n).find((t) => t.info.argv.slice(0, argv.length).join(" ") === argv.join(" "));
618
- if (!j) return `${btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`)}${btn("…", () => tunnelOptions(n, argv, ports), "sm ghost", "Pick the ports, or make it reachable from your network")}`;
637
+ if (!j)
638
+ return `${btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`)}${btn(
639
+ "On network",
640
+ () => run([...argv, "--lan"], { server: n }),
641
+ "sm",
642
+ "Open it for every device on your network too (a phone, a colleague): anyone there reaches it at this machine's address",
643
+ )}${btn("…", () => tunnelOptions(n, argv, ports), "sm", "Container port, local port, network")}`;
619
644
  const url = tunnelUrl(j);
620
645
  // A database's address is for a client, not the browser.
621
646
  const link = (u) => u.startsWith("http://") ? `<a class="tunnel-url" href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace("http://", ""))}</a>` : `<code class="tunnel-url" title="Connect a client to it">${esc(u)}</code>`;
@@ -785,9 +810,9 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
785
810
  }
786
811
  const invalid = (field, error) => ({ field, error });
787
812
  /** What is wrong with a login (lf newLogin's rules), as [field, message]. */
788
- const loginError = (user, password) =>
789
- !/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
790
- : password.length < 8 ? ["password", "The password needs at least 8 characters"]
813
+ const loginError = (user, password, { loginName, minPassword } = S.state.rules) =>
814
+ !new RegExp(loginName).test(user) ? ["user", "Only letters, digits and . _ @ -"]
815
+ : password.length < minPassword ? ["password", `The password needs at least ${minPassword} characters`]
791
816
  : null;
792
817
  /**
793
818
  * Passwords here are for servers and sites, not for this page: a masked text field, so browsers
@@ -816,10 +841,10 @@ function setupFlow(args, base = args) {
816
841
  server: args[0],
817
842
  after: (j) => {
818
843
  if (j.info.code !== 0) return;
819
- if (j.lines.some((l) => l.includes("no key login yet"))) {
844
+ if (rep(j).needsKey) {
820
845
  return [{ label: "Enter the password to install a key", run: () => passwordFor(args, base) }];
821
846
  }
822
- const todo = j.lines.filter((l) => /^\s+→ /.test(l)).length;
847
+ const todo = (rep(j).checks ?? []).filter((ch) => ch.status === "todo").length;
823
848
  return [
824
849
  { label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0] }) },
825
850
  { label: "Change options", run: () => setupWizard(base) },
@@ -932,14 +957,13 @@ function addServer() {
932
957
  }
933
958
 
934
959
  const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
935
- const sslip = (name, ip) => `${name}.${ip.replace(/\./g, "-")}.sslip.io`;
936
960
  const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
937
961
  /** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
938
962
  function autoDomain(n, name) {
939
963
  const main = mainDomain(n);
940
964
  if (main) return `${name}.${main}`;
941
- const ip = S.facts[n]?.data?.sslipIp;
942
- return ip ? sslip(name, ip) : "";
965
+ const suffix = S.facts[n]?.data?.sslipSuffix;
966
+ return suffix ? `${name}.${suffix}` : "";
943
967
  }
944
968
  const slugify = (s) => s.toLowerCase().replace(/\.(zip|tgz|tar(\.gz)?)$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "site";
945
969
  const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
@@ -989,7 +1013,7 @@ function deployDialog(det, o = {}) {
989
1013
  { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
990
1014
  ]
991
1015
  : []),
992
- { name: "authPassword", label: "Login password (optional, at least 8 characters)", type: "password", hint: "Visitors log in with the user below and this password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
1016
+ { name: "authPassword", label: `Login password (optional, at least ${S.state.rules.minPassword} characters)`, type: "password", hint: "Visitors log in with the user below and this password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
993
1017
  { name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
994
1018
  ],
995
1019
  ok: "Deploy",
@@ -1021,7 +1045,7 @@ function deployDialog(det, o = {}) {
1021
1045
  /** After a deploy: offer the address it printed. */
1022
1046
  function openAfter(j) {
1023
1047
  if (j.info.code !== 0) return;
1024
- const url = j.lines.map((l) => l.replace(ANSI, "").match(/https:\/\/[^\s)]+/)?.[0]).filter(Boolean).pop();
1048
+ const url = rep(j).url;
1025
1049
  if (url) return [{ label: `Open ${url.replace("https://", "")}`, run: () => window.open(url, "_blank", "noopener") }];
1026
1050
  }
1027
1051
 
@@ -1180,7 +1204,7 @@ function authDialog(n, m) {
1180
1204
  : "Visitors have to log in (HTTP basic auth) before they see the site.",
1181
1205
  fields: [
1182
1206
  { name: "user", label: "User", value: users[0] ?? "admin" },
1183
- { name: "password", label: "Password (at least 8 characters)", type: "password", auto: "new-password" },
1207
+ { name: "password", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", auto: "new-password" },
1184
1208
  ...(users.length ? [{ name: "off", label: "Remove the login: the site is public again", type: "check", value: false }] : []),
1185
1209
  ],
1186
1210
  ok: "Save",
@@ -1189,7 +1213,7 @@ function authDialog(n, m) {
1189
1213
  const user = v.user.trim() || "admin";
1190
1214
  const bad = loginError(user, v.password);
1191
1215
  if (bad) return invalid(...bad);
1192
- run(["auth", m.name, ...sv(n), "-u", user, "-p", v.password], { server: n });
1216
+ run(["auth", m.name, ...sv(n), "-u", user, "--password", v.password], { server: n });
1193
1217
  },
1194
1218
  });
1195
1219
  }
@@ -1235,14 +1259,8 @@ function forget(n) {
1235
1259
  });
1236
1260
  }
1237
1261
 
1238
- async function makeDefault(n) {
1239
- try {
1240
- await http(`/api/servers/${encodeURIComponent(n)}/default`, {});
1241
- await refreshState();
1242
- toast(`${n} is now the default server`);
1243
- } catch (e) {
1244
- toast(e.message);
1245
- }
1262
+ function makeDefault(n) {
1263
+ run(["server", "use", n], { server: n, after: (j) => (j.info.code === 0 ? void refreshState() : undefined) });
1246
1264
  }
1247
1265
 
1248
1266
  // ---- views ---------------------------------------------------------------
@@ -1828,7 +1846,7 @@ function traefikView(n) {
1828
1846
  fields: [
1829
1847
  { name: "domain", label: "Domain", value: x.traefik.dashboard ?? "", placeholder: x.domains[0] ? `traefik.${x.domains[0]}` : "traefik.example.com", hint: x.traefik.dashboard ? "Empty: take the dashboard off its domain." : "Needs an A record pointing at the server." },
1830
1848
  { name: "user", label: "Login user", value: "admin", placeholder: "admin" },
1831
- { name: "password", label: "Login password (at least 8 characters)", type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
1849
+ { name: "password", label: `Login password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
1832
1850
  ],
1833
1851
  ok: "Apply",
1834
1852
  onOk: (v) => {
@@ -1879,7 +1897,7 @@ function traefikView(n) {
1879
1897
  <div class="body">${rows([
1880
1898
  ["domain", dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">none${foreign ? "" : ": only through the tunnel"}</span>`],
1881
1899
  ["tunnel", dash.tunnel
1882
- ? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim">lf traefik dashboard</span>`
1900
+ ? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim mono">lf traefik dashboard</span>`
1883
1901
  : foreign ? `<span class="dim">route its dashboard (api@internal) to a domain, or run <span class="mono">lf traefik dashboard --port &lt;its api port&gt;</span></span>`
1884
1902
  : `<span class="dim">set up by an older landfall</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`],
1885
1903
  ])}</div>