@axis-network/butter-col 0.0.0-stage → 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/bin/butter-col.js +4 -0
  4. package/dist/bootstrap.html +67 -0
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +7 -0
  7. package/dist/controller.mjs +2 -0
  8. package/dist/controller.mjs.map +7 -0
  9. package/dist/formatter.mjs +2 -0
  10. package/dist/formatter.mjs.map +7 -0
  11. package/dist/sw.js +30 -0
  12. package/dist/sw.js.map +7 -0
  13. package/dist/transports/butter.js +2 -0
  14. package/dist/transports/butter.js.map +7 -0
  15. package/dist/types/controller/index.d.ts +65 -0
  16. package/dist/types/formatter/codecs.d.ts +17 -0
  17. package/dist/types/formatter/css.d.ts +10 -0
  18. package/dist/types/formatter/html.d.ts +61 -0
  19. package/dist/types/formatter/index.d.ts +77 -0
  20. package/dist/types/formatter/js.d.ts +27 -0
  21. package/dist/types/formatter/url.d.ts +40 -0
  22. package/lib/cli.js +257 -0
  23. package/lib/index.d.ts +68 -0
  24. package/lib/index.js +166 -0
  25. package/package.json +77 -4
  26. package/server/conf/butter-proxy.conf.template +29 -0
  27. package/server/conf/mime.types +19 -0
  28. package/server/conf/nginx.conf.template +152 -0
  29. package/server/config.lua +34 -0
  30. package/server/lua/butter/config.lua +160 -0
  31. package/server/lua/butter/cookiejar.lua +197 -0
  32. package/server/lua/butter/dns.lua +67 -0
  33. package/server/lua/butter/guard.lua +78 -0
  34. package/server/lua/butter/headers.lua +83 -0
  35. package/server/lua/butter/init.lua +18 -0
  36. package/server/lua/butter/ip.lua +165 -0
  37. package/server/lua/butter/limit.lua +18 -0
  38. package/server/lua/butter/session.lua +42 -0
  39. package/server/lua/butter/store.lua +77 -0
  40. package/server/lua/butter/transport.lua +248 -0
  41. package/server/lua/butter/url.lua +78 -0
  42. package/site/app.css +373 -0
  43. package/site/app.js +335 -0
  44. package/site/butter-col.png +0 -0
  45. package/site/index.html +139 -0
@@ -0,0 +1,248 @@
1
+ -- nginx phase handlers for butter-col's endpoints.
2
+ --
3
+ -- /butter/fetch HTTP transport (access + header_filter around proxy_pass)
4
+ -- /butter/ws WebSocket transport (access + header_filter around proxy_pass)
5
+ -- /butter/cookie document.cookie write-back
6
+ -- /butter/config.js, /butter/health
7
+ --
8
+ -- The Lua code validates and prepares each request; nginx's proxy module
9
+ -- does the actual streaming, keep-alive and TLS work.
10
+
11
+ local cjson = require "cjson.safe"
12
+ local config = require "butter.config"
13
+ local url = require "butter.url"
14
+ local guard = require "butter.guard"
15
+ local headers = require "butter.headers"
16
+ local session = require "butter.session"
17
+ local store = require "butter.store"
18
+ local limit = require "butter.limit"
19
+
20
+ local M = {}
21
+
22
+ local function error_json(code, message)
23
+ return cjson.encode({ code = code, message = message })
24
+ end
25
+
26
+ --- Send a JSON error and finish the request.
27
+ local function fail(status, code, message)
28
+ ngx.status = status
29
+ ngx.header["Content-Type"] = "application/json"
30
+ ngx.header["Cache-Control"] = "no-store"
31
+ ngx.header["X-Butter-Error"] = error_json(code, message)
32
+ ngx.say(cjson.encode({ error = { code = code, message = message } }))
33
+ return ngx.exit(ngx.HTTP_OK)
34
+ end
35
+
36
+ --- "https://host:port" -> does it name this server?
37
+ local function origin_is_self(origin)
38
+ local host = origin:match("^%a[%w+.-]*://([^/]+)$")
39
+ return host ~= nil and host:lower() == (ngx.var.http_host or ""):lower()
40
+ end
41
+
42
+ --- Was this request made by our own pages (and not by another website)?
43
+ local function same_origin()
44
+ local cfg = config.get()
45
+ if not cfg.require_same_origin then return true end
46
+ local site = ngx.var.http_sec_fetch_site
47
+ if site then return site == "same-origin" end
48
+ local origin = ngx.var.http_origin
49
+ if origin then return origin_is_self(origin) end
50
+ return cfg.allow_non_browser
51
+ end
52
+
53
+ --- Shared checks; returns the parsed target and the upstream address.
54
+ local function prepare(raw_url, allowed_schemes)
55
+ local cfg = config.get()
56
+ if not limit.check(cfg.rate_limit) then
57
+ return fail(429, "RATE_LIMITED", "Too many requests. Slow down a little.")
58
+ end
59
+ if not same_origin() then
60
+ return fail(403, "FORBIDDEN_ORIGIN", "Transport requests must come from this server's own pages.")
61
+ end
62
+ local target, err = url.parse(raw_url)
63
+ if not target then return fail(400, "INVALID_URL", err) end
64
+ if not allowed_schemes[target.scheme] then
65
+ return fail(400, "INVALID_URL", "Unsupported scheme " .. target.scheme .. ".")
66
+ end
67
+ local dest, code, message = guard.check(target)
68
+ if not dest then return fail(code == "DNS_FAILURE" and 502 or 403, code, message) end
69
+ return target, dest
70
+ end
71
+
72
+ local function set_upstream(scheme, target, dest)
73
+ ngx.var.butter_scheme = scheme
74
+ ngx.var.butter_addr = dest.addr
75
+ ngx.var.butter_uri = target.path
76
+ ngx.var.butter_host = target.host_header
77
+ ngx.var.butter_sni = target.host
78
+ end
79
+
80
+ --- Remove every incoming header except the ones in `keep`.
81
+ local function clear_request_headers(keep)
82
+ for name in pairs(ngx.req.get_headers(200, true)) do
83
+ if not keep[name:lower()] then ngx.req.clear_header(name) end
84
+ end
85
+ end
86
+
87
+ local function apply_cookie(sid, target)
88
+ if not config.get().cookies.enabled then return end
89
+ local cookie = store.cookie_header(sid, target)
90
+ if cookie ~= "" then ngx.req.set_header("Cookie", cookie) end
91
+ end
92
+
93
+ local HTTP_SCHEMES = { http = true, https = true }
94
+ local WS_SCHEMES = { ws = true, wss = true }
95
+
96
+ -- /butter/fetch ---------------------------------------------------------------
97
+
98
+ function M.access_fetch()
99
+ local target, dest = prepare(ngx.var.http_x_butter_url, HTTP_SCHEMES)
100
+ if not target then return end
101
+
102
+ local decoded = cjson.decode(ngx.var.http_x_butter_headers or "{}")
103
+ local list, err = headers.sanitize_request(decoded)
104
+ if not list then return fail(400, "INVALID_HEADERS", err) end
105
+
106
+ local want_cookies = ngx.var.http_x_butter_want_cookies == "1"
107
+ local sid, new_session = session.get()
108
+
109
+ clear_request_headers(headers.KEEP_OUTER)
110
+ for _, header in ipairs(list) do
111
+ ngx.req.set_header(header[1], header[2])
112
+ end
113
+ local cfg = config.get()
114
+ if cfg.user_agent then ngx.req.set_header("User-Agent", cfg.user_agent) end
115
+ apply_cookie(sid, target)
116
+
117
+ set_upstream(target.scheme, target, dest)
118
+ ngx.ctx.butter = { sid = sid, new_session = new_session, target = target, want_cookies = want_cookies }
119
+ end
120
+
121
+ --- Collect upstream Set-Cookie headers into the jar.
122
+ local function capture_cookies(ctx, values)
123
+ if #values > 0 and config.get().cookies.enabled then
124
+ store.set_cookies(ctx.sid, ctx.target, values)
125
+ end
126
+ end
127
+
128
+ function M.header_filter_fetch()
129
+ local ctx = ngx.ctx.butter
130
+ if not ctx then return end
131
+
132
+ local status = ngx.status
133
+ local reported, cookies = {}, {}
134
+ for name, value in pairs(ngx.resp.get_headers(200, true)) do
135
+ local lower = name:lower()
136
+ local values = type(value) == "table" and value or { value }
137
+ if lower == "set-cookie" then
138
+ for _, v in ipairs(values) do cookies[#cookies + 1] = v end
139
+ elseif not headers.RESPONSE_SKIP[lower] then
140
+ for _, v in ipairs(values) do reported[#reported + 1] = { name, v } end
141
+ end
142
+ if not headers.OUTER_KEEP[lower] then ngx.header[name] = nil end
143
+ end
144
+ capture_cookies(ctx, cookies)
145
+
146
+ -- The real status travels in a header: a 3xx would make the browser follow
147
+ -- the redirect itself, and a 401 would pop up an auth dialog.
148
+ ngx.status = ngx.HTTP_OK
149
+ ngx.header["X-Butter-Status"] = tostring(status)
150
+ ngx.header["X-Butter-Status-Text"] = headers.reason(status)
151
+ ngx.header["X-Butter-Headers"] = #reported == 0 and "[]" or cjson.encode(reported)
152
+ ngx.header["Content-Type"] = "application/octet-stream"
153
+ ngx.header["Cache-Control"] = "no-store"
154
+ if ctx.want_cookies then
155
+ ngx.header["X-Butter-Document-Cookies"] = store.document_cookie(ctx.sid, ctx.target)
156
+ end
157
+ if ctx.new_session then ngx.header["Set-Cookie"] = session.cookie(ctx.sid) end
158
+ end
159
+
160
+ -- /butter/ws -------------------------------------------------------------------
161
+
162
+ function M.access_ws()
163
+ if (ngx.var.http_upgrade or ""):lower() ~= "websocket" then
164
+ return fail(400, "NOT_WEBSOCKET", "Expected a WebSocket upgrade request.")
165
+ end
166
+ local args = ngx.req.get_uri_args(10)
167
+ local raw_url = type(args.url) == "string" and args.url or nil
168
+ local target, dest = prepare(raw_url, WS_SCHEMES)
169
+ if not target then return end
170
+
171
+ -- Origin the target site should see: the proxied page's own origin.
172
+ local page_origin = type(args.origin) == "string" and url.parse(args.origin .. "/")
173
+ local origin = page_origin and HTTP_SCHEMES[page_origin.scheme] and page_origin.origin
174
+ or ((target.secure and "https://" or "http://") .. target.host_header)
175
+
176
+ local sid, new_session = session.get()
177
+ for _, name in ipairs({ "Cookie", "Origin", "Referer", "X-Forwarded-For", "X-Real-IP", "Forwarded" }) do
178
+ ngx.req.clear_header(name)
179
+ end
180
+ ngx.req.set_header("Origin", origin)
181
+ local cfg = config.get()
182
+ if cfg.user_agent then ngx.req.set_header("User-Agent", cfg.user_agent) end
183
+ apply_cookie(sid, target)
184
+
185
+ set_upstream(target.secure and "https" or "http", target, dest)
186
+ ngx.ctx.butter = { sid = sid, new_session = new_session, target = target }
187
+ end
188
+
189
+ function M.header_filter_ws()
190
+ local ctx = ngx.ctx.butter
191
+ if not ctx then return end
192
+ local values = ngx.header["Set-Cookie"]
193
+ if values then
194
+ capture_cookies(ctx, type(values) == "table" and values or { values })
195
+ ngx.header["Set-Cookie"] = nil
196
+ end
197
+ if ctx.new_session then ngx.header["Set-Cookie"] = session.cookie(ctx.sid) end
198
+ end
199
+
200
+ -- Errors from nginx's proxy module (connection refused, TLS failure, timeout).
201
+ function M.upstream_error()
202
+ local status = ngx.status
203
+ if status == 504 then
204
+ return fail(504, "UPSTREAM_TIMEOUT", "The site took too long to respond.")
205
+ end
206
+ return fail(502, "UPSTREAM_UNREACHABLE", "Could not connect to the site (connection refused or reset, or its TLS certificate could not be verified).")
207
+ end
208
+
209
+ -- /butter/cookie ---------------------------------------------------------------
210
+
211
+ function M.cookie()
212
+ if ngx.req.get_method() ~= "POST" then return fail(405, "METHOD_NOT_ALLOWED", "Use POST.") end
213
+ if not same_origin() then return fail(403, "FORBIDDEN_ORIGIN", "Cross-site cookie writes are not allowed.") end
214
+ if not config.get().cookies.enabled then return ngx.exit(ngx.HTTP_NO_CONTENT) end
215
+
216
+ ngx.req.read_body()
217
+ local data = cjson.decode(ngx.req.get_body_data() or "")
218
+ if type(data) ~= "table" or type(data.url) ~= "string" or type(data.cookie) ~= "string" or #data.cookie > 4096 then
219
+ return fail(400, "INVALID_COOKIE", "Expected {\"url\": string, \"cookie\": string}.")
220
+ end
221
+ local target, err = url.parse(data.url)
222
+ if not target or not HTTP_SCHEMES[target.scheme] then return fail(400, "INVALID_URL", err or "Unsupported scheme.") end
223
+
224
+ local sid = session.ensure()
225
+ store.set_cookies(sid, target, { data.cookie }, { from_script = true })
226
+ return ngx.exit(ngx.HTTP_NO_CONTENT)
227
+ end
228
+
229
+ -- /butter/config.js ------------------------------------------------------------
230
+
231
+ function M.config_js()
232
+ -- Handing out the session cookie here (the service worker imports this
233
+ -- script on install) means it exists before the first proxied request.
234
+ session.ensure()
235
+ ngx.header["Content-Type"] = "text/javascript; charset=utf-8"
236
+ ngx.header["Cache-Control"] = "no-cache"
237
+ ngx.say("self.__butter$config = ", cjson.encode(config.public()), ";")
238
+ end
239
+
240
+ -- /butter/health ---------------------------------------------------------------
241
+
242
+ function M.health()
243
+ ngx.header["Content-Type"] = "application/json"
244
+ ngx.header["Cache-Control"] = "no-store"
245
+ ngx.say(cjson.encode({ ok = true, name = "butter-col", version = config.VERSION }))
246
+ end
247
+
248
+ return M
@@ -0,0 +1,78 @@
1
+ -- Strict parser for the absolute URLs the service worker sends.
2
+ -- Pure Lua (5.1 / LuaJIT / 5.3), no ngx dependency.
3
+
4
+ local M = {}
5
+
6
+ local DEFAULT_PORTS = { http = 80, https = 443, ws = 80, wss = 443 }
7
+ local SECURE = { https = true, wss = true }
8
+
9
+ --- Parse an absolute http(s)/ws(s) URL.
10
+ -- @return table|nil, string|nil parsed URL, or nil and an error message
11
+ function M.parse(s)
12
+ if type(s) ~= "string" or s == "" then return nil, "missing URL" end
13
+ if #s > 8192 then return nil, "URL is too long" end
14
+ -- URL.href from the browser is always ASCII without spaces or controls;
15
+ -- anything else could smuggle CR/LF into the upstream request line.
16
+ if s:find("%c") or s:find(" ", 1, true) or s:find("[\128-\255]") then
17
+ return nil, "URL contains invalid characters"
18
+ end
19
+
20
+ local scheme, rest = s:match("^(%a[%w+.-]*)://(.*)$")
21
+ if not scheme then return nil, "not an absolute URL" end
22
+ scheme = scheme:lower()
23
+ local default_port = DEFAULT_PORTS[scheme]
24
+ if not default_port then return nil, "unsupported scheme: " .. scheme end
25
+
26
+ local authority, path = rest:match("^([^/?#]*)(.*)$")
27
+ path = path:gsub("#.*$", "")
28
+ if path == "" then
29
+ path = "/"
30
+ elseif path:sub(1, 1) == "?" then
31
+ path = "/" .. path
32
+ end
33
+ if path:sub(1, 1) ~= "/" then return nil, "invalid path" end
34
+
35
+ if authority:find("@", 1, true) then return nil, "credentials in URLs are not allowed" end
36
+
37
+ local host, port, ipv6
38
+ if authority:sub(1, 1) == "[" then
39
+ host, port = authority:match("^%[([%x:%.]+)%]:?(%d*)$")
40
+ if not host then return nil, "invalid IPv6 host" end
41
+ ipv6 = true
42
+ else
43
+ host, port = authority:match("^([^:]+):?(%d*)$")
44
+ if not host then return nil, "invalid host" end
45
+ host = host:gsub("%.$", "")
46
+ if host == "" or #host > 253 or not host:match("^[%w%-%._]+$") then
47
+ return nil, "invalid host"
48
+ end
49
+ end
50
+ host = host:lower()
51
+
52
+ local port_num = default_port
53
+ if port ~= "" then
54
+ port_num = tonumber(port)
55
+ if not port_num or port_num < 1 or port_num > 65535 then return nil, "invalid port" end
56
+ end
57
+
58
+ local host_header = ipv6 and ("[" .. host .. "]") or host
59
+ if port_num ~= default_port then host_header = host_header .. ":" .. string.format("%d", port_num) end
60
+
61
+ return {
62
+ scheme = scheme,
63
+ host = host,
64
+ port = port_num,
65
+ path = path, -- path and query, percent-encoded
66
+ ipv6 = ipv6 or false,
67
+ secure = SECURE[scheme] or false,
68
+ host_header = host_header, -- value for the Host header
69
+ origin = scheme .. "://" .. host_header,
70
+ }
71
+ end
72
+
73
+ --- Path without the query string.
74
+ function M.path_only(path)
75
+ return (path:gsub("%?.*$", ""))
76
+ end
77
+
78
+ return M
package/site/app.css ADDED
@@ -0,0 +1,373 @@
1
+ /* butter-col frontend
2
+ *
3
+ * The identity comes from the logo: a glossy stick of butter with a thick
4
+ * orange outline, on a cool pantry-blue night. The omnibox is drawn as that
5
+ * stick of butter; everything around it stays quiet.
6
+ */
7
+
8
+ :root {
9
+ --pantry: #1a2142; /* page background */
10
+ --shelf: #242d57; /* toolbar, panels */
11
+ --shelf-high: #2f3a6b; /* hover, inputs on panels */
12
+ --line: #3a467c;
13
+ --milk: #fff7dc; /* text */
14
+ --haze: #a3abd1; /* secondary text */
15
+ --butter: #ffd447;
16
+ --butter-light: #ffe58a;
17
+ --butter-deep: #f5bd2c;
18
+ --crust: #f08a1c; /* the logo's orange outline */
19
+ --crust-deep: #c9650b;
20
+ --on-butter: #3b2a05; /* text on butter */
21
+ --danger: #ff8f7a;
22
+ --font-display: "Fredoka", "Trebuchet MS", system-ui, sans-serif;
23
+ --font-text: "Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", sans-serif;
24
+ color-scheme: dark;
25
+ }
26
+
27
+ /* light: a pale periwinkle kitchen in daylight */
28
+ @media (prefers-color-scheme: light) {
29
+ :root:not([data-theme="dark"]) {
30
+ --pantry: #eef0ff;
31
+ --shelf: #ffffff;
32
+ --shelf-high: #e3e7fb;
33
+ --line: #cdd3f2;
34
+ --milk: #1a2142;
35
+ --haze: #59618c;
36
+ --danger: #b3261e;
37
+ color-scheme: light;
38
+ }
39
+ }
40
+ :root[data-theme="light"] {
41
+ --pantry: #eef0ff;
42
+ --shelf: #ffffff;
43
+ --shelf-high: #e3e7fb;
44
+ --line: #cdd3f2;
45
+ --milk: #1a2142;
46
+ --haze: #59618c;
47
+ --danger: #b3261e;
48
+ color-scheme: light;
49
+ }
50
+
51
+ *, *::before, *::after { box-sizing: border-box; }
52
+
53
+ html, body { height: 100%; }
54
+
55
+ body {
56
+ margin: 0;
57
+ background: var(--pantry);
58
+ color: var(--milk);
59
+ font: 17px/1.5 var(--font-text);
60
+ -webkit-font-smoothing: antialiased;
61
+ }
62
+
63
+ button, input, select { font: inherit; color: inherit; }
64
+
65
+ :focus-visible { outline: 3px solid var(--crust); outline-offset: 2px; }
66
+
67
+ [hidden] { display: none !important; }
68
+
69
+ .visually-hidden {
70
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
71
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
72
+ }
73
+
74
+ /* ---- shared controls ---------------------------------------------------- */
75
+
76
+ .icon-button {
77
+ flex: none;
78
+ display: inline-grid;
79
+ place-items: center;
80
+ width: 40px;
81
+ height: 40px;
82
+ border: 0;
83
+ border-radius: 12px;
84
+ background: transparent;
85
+ color: var(--haze);
86
+ cursor: pointer;
87
+ }
88
+ .icon-button:hover { background: var(--shelf-high); color: var(--milk); }
89
+ .icon-button:disabled { opacity: 0.35; cursor: default; background: transparent; }
90
+ .icon-button svg {
91
+ width: 22px; height: 22px;
92
+ fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
93
+ }
94
+
95
+ .text-button {
96
+ border: 0;
97
+ padding: 0 0.15em;
98
+ background: none;
99
+ color: var(--butter);
100
+ font-weight: 700;
101
+ text-decoration: underline;
102
+ text-underline-offset: 3px;
103
+ cursor: pointer;
104
+ }
105
+ :root[data-theme="light"] .text-button { color: var(--crust-deep); }
106
+ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .text-button { color: var(--crust-deep); } }
107
+
108
+ .secondary-button {
109
+ justify-self: start;
110
+ padding: 0.55rem 1rem;
111
+ border: 2px solid var(--line);
112
+ border-radius: 12px;
113
+ background: var(--shelf-high);
114
+ font-weight: 700;
115
+ cursor: pointer;
116
+ }
117
+ .secondary-button:hover { border-color: var(--crust); }
118
+
119
+ .notice {
120
+ margin: 1rem auto 0;
121
+ max-width: 34rem;
122
+ padding: 0.75rem 1rem;
123
+ border-radius: 14px;
124
+ border: 2px solid var(--danger);
125
+ color: var(--milk);
126
+ background: color-mix(in srgb, var(--danger) 14%, transparent);
127
+ text-align: left;
128
+ }
129
+ .notice[data-kind="info"] { border-color: var(--line); background: var(--shelf-high); }
130
+
131
+ /* ---- home --------------------------------------------------------------- */
132
+
133
+ .home {
134
+ min-height: 100%;
135
+ display: grid;
136
+ grid-template-rows: auto 1fr auto;
137
+ padding: 16px 16px 40px;
138
+ }
139
+
140
+ .home-bar { display: flex; justify-content: flex-end; }
141
+
142
+ .hero {
143
+ align-self: center;
144
+ justify-self: center;
145
+ width: min(44rem, 100%);
146
+ text-align: center;
147
+ padding: 2vh 0 4vh;
148
+ }
149
+
150
+ .hero-logo {
151
+ display: block;
152
+ width: min(26rem, 82vw);
153
+ height: auto;
154
+ margin: 0 auto 1.25rem;
155
+ transform-origin: 50% 90%;
156
+ animation: settle 700ms cubic-bezier(.2, 1.4, .4, 1) both;
157
+ }
158
+ @keyframes settle {
159
+ from { transform: translateY(-14px) rotate(-3deg) scale(0.96); opacity: 0; }
160
+ to { transform: none; opacity: 1; }
161
+ }
162
+
163
+ /* The stick of butter. */
164
+ .stick {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ padding: 8px 8px 8px 22px;
169
+ border: 4px solid var(--crust);
170
+ border-radius: 24px;
171
+ background:
172
+ linear-gradient(180deg, rgb(255 255 255 / 0.55) 0 6px, transparent 6px) top / 100% 6px no-repeat,
173
+ linear-gradient(180deg, var(--butter-light) 0%, var(--butter) 46%, var(--butter-deep) 100%);
174
+ box-shadow:
175
+ inset 0 -6px 0 rgb(201 101 11 / 0.22),
176
+ 0 7px 0 var(--crust-deep),
177
+ 0 18px 34px rgb(5 8 30 / 0.35);
178
+ }
179
+ .stick:focus-within { outline: 3px solid var(--milk); outline-offset: 4px; }
180
+ .stick:focus-within:has(:focus:not(:focus-visible)) { outline: none; }
181
+
182
+ .stick-input {
183
+ flex: 1;
184
+ min-width: 0;
185
+ border: 0;
186
+ background: transparent;
187
+ color: var(--on-butter);
188
+ font-family: var(--font-display);
189
+ font-weight: 500;
190
+ font-size: clamp(1.1rem, 2.6vw, 1.35rem);
191
+ padding: 0.55rem 0;
192
+ }
193
+ .stick-input::placeholder { color: rgb(59 42 5 / 0.62); }
194
+ .stick-input:focus { outline: none; }
195
+
196
+ .stick-go {
197
+ flex: none;
198
+ border: 0;
199
+ border-radius: 16px;
200
+ padding: 0.6rem 1.4rem;
201
+ background: var(--crust);
202
+ color: #fff7dc;
203
+ font-family: var(--font-display);
204
+ font-weight: 700;
205
+ font-size: 1.15rem;
206
+ cursor: pointer;
207
+ box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.18);
208
+ }
209
+ .stick-go:hover { background: var(--crust-deep); }
210
+ .stick-go:disabled { opacity: 0.6; cursor: progress; }
211
+
212
+ .transport-line {
213
+ margin: 1.6rem 0 0;
214
+ color: var(--haze);
215
+ font-size: 0.95rem;
216
+ }
217
+ .transport-line strong { color: var(--milk); font-weight: 700; }
218
+
219
+ .recent {
220
+ justify-self: center;
221
+ width: min(34rem, 100%);
222
+ }
223
+ .recent h2 {
224
+ margin: 0 0 0.5rem;
225
+ font: 600 1.05rem/1.3 var(--font-display);
226
+ color: var(--haze);
227
+ }
228
+ .recent-list { list-style: none; margin: 0; padding: 0; }
229
+ .recent-list button {
230
+ display: grid;
231
+ grid-template-columns: 2.25rem 1fr;
232
+ gap: 0.15rem 0.8rem;
233
+ align-items: center;
234
+ width: 100%;
235
+ padding: 0.5rem 0.6rem;
236
+ border: 0;
237
+ border-radius: 14px;
238
+ background: transparent;
239
+ text-align: left;
240
+ cursor: pointer;
241
+ }
242
+ .recent-list button:hover { background: var(--shelf); }
243
+ .recent-badge {
244
+ grid-row: span 2;
245
+ display: grid;
246
+ place-items: center;
247
+ width: 2.25rem;
248
+ height: 2.25rem;
249
+ border-radius: 10px;
250
+ border: 2px solid var(--crust);
251
+ background: var(--butter);
252
+ color: var(--on-butter);
253
+ font: 700 1.05rem/1 var(--font-display);
254
+ text-transform: uppercase;
255
+ }
256
+ .recent-title { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
257
+ .recent-host { color: var(--haze); font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
258
+ .recent-empty { margin: 0; color: var(--haze); font-size: 0.95rem; }
259
+
260
+ /* ---- browser ------------------------------------------------------------ */
261
+
262
+ .browser {
263
+ position: fixed;
264
+ inset: 0;
265
+ display: grid;
266
+ grid-template-rows: auto 1fr;
267
+ }
268
+
269
+ .toolbar {
270
+ position: relative;
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 4px;
274
+ padding: 8px 10px;
275
+ background: var(--shelf);
276
+ border-bottom: 1px solid var(--line);
277
+ }
278
+
279
+ .address {
280
+ flex: 1;
281
+ min-width: 0;
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 8px;
285
+ margin: 0 6px;
286
+ padding: 0 14px;
287
+ height: 42px;
288
+ border: 2px solid transparent;
289
+ border-radius: 14px;
290
+ background: var(--pantry);
291
+ }
292
+ .address:focus-within { border-color: var(--crust); }
293
+ .address-lock {
294
+ flex: none;
295
+ width: 17px; height: 17px;
296
+ fill: none; stroke: var(--haze); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
297
+ }
298
+ .address-lock[data-insecure] { stroke: var(--danger); }
299
+ .address-input {
300
+ flex: 1;
301
+ min-width: 0;
302
+ border: 0;
303
+ background: transparent;
304
+ font-size: 0.98rem;
305
+ }
306
+ .address-input:focus { outline: none; }
307
+
308
+ .progress {
309
+ position: absolute;
310
+ left: 0;
311
+ bottom: -1px;
312
+ height: 3px;
313
+ width: 0;
314
+ background: linear-gradient(90deg, var(--butter), var(--crust));
315
+ opacity: 0;
316
+ transition: width 1.6s cubic-bezier(.1, .7, .2, 1), opacity 0.3s;
317
+ }
318
+ .progress[data-state="loading"] { width: 82%; opacity: 1; }
319
+ .progress[data-state="done"] { width: 100%; opacity: 0; transition: width 0.2s, opacity 0.4s 0.2s; }
320
+
321
+ .frame {
322
+ width: 100%;
323
+ height: 100%;
324
+ border: 0;
325
+ background: #fff;
326
+ }
327
+
328
+ /* ---- settings ----------------------------------------------------------- */
329
+
330
+ .settings {
331
+ width: min(30rem, calc(100vw - 32px));
332
+ padding: 0;
333
+ border: 2px solid var(--line);
334
+ border-radius: 22px;
335
+ background: var(--shelf);
336
+ color: var(--milk);
337
+ box-shadow: 0 24px 60px rgb(5 8 30 / 0.45);
338
+ }
339
+ .settings::backdrop { background: rgb(10 13 34 / 0.6); }
340
+ .settings[open] { animation: rise 180ms ease-out; }
341
+ @keyframes rise { from { transform: translateY(10px); opacity: 0; } }
342
+
343
+ .settings-body { display: grid; gap: 1.2rem; padding: 18px 22px 24px; }
344
+ .settings-head { display: flex; align-items: center; justify-content: space-between; }
345
+ .settings-head h2 { margin: 0; font: 600 1.4rem/1.2 var(--font-display); }
346
+
347
+ .field { display: grid; gap: 0.4rem; }
348
+ .field label, .field-label { font-weight: 700; }
349
+ .field select {
350
+ width: 100%;
351
+ padding: 0.6rem 0.75rem;
352
+ border: 2px solid var(--line);
353
+ border-radius: 12px;
354
+ background: var(--shelf-high);
355
+ }
356
+ .field select:focus-visible { border-color: var(--crust); outline: none; }
357
+ .hint { margin: 0; color: var(--haze); font-size: 0.9rem; }
358
+ .settings .notice { margin: 0; }
359
+
360
+ /* ---- small screens ------------------------------------------------------ */
361
+
362
+ @media (max-width: 560px) {
363
+ .toolbar { gap: 0; padding: 6px; }
364
+ .icon-button { width: 36px; height: 36px; }
365
+ .toolbar [data-action="fullscreen"], .toolbar [data-action="popout"] { display: none; }
366
+ .address { margin: 0 4px; padding: 0 10px; }
367
+ .stick { padding-left: 16px; border-radius: 20px; }
368
+ .stick-go { padding: 0.55rem 1rem; }
369
+ }
370
+
371
+ @media (prefers-reduced-motion: reduce) {
372
+ *, *::before, *::after { animation: none !important; transition: none !important; }
373
+ }