1 Commits
Author SHA1 Message Date
fen c32566979f #260: .swapbtn grid for copy feedback (fix attempt 2)
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 23s
Label and checkmark stack in one grid cell (inline-grid, grid-area 1/1,
checkmark visibility-hidden until .ok), so the button width is static by
construction across the click. Replaces the min-width pin from fix attempt
1, which enlarged buttons by up to 0.64px (ceil of fractional width) and
leaked the pin after restore.

Applies to paste-view Copy/Link (paste.html + paste.js copyFeedback) and
the /new result-box copy button (new.js). Verified with CDP bounding-box
probes: Copy width/x identical before/during/after at 1400x900 and
375x812 (including the previously failing 52.36px mobile case), Link
button static, checkmark visible during feedback, no minWidth pin set.
2026-09-17 17:18:15 -05:00
17 changed files with 146 additions and 270 deletions
+4 -4
View File
@@ -21,16 +21,16 @@ a web UI for sharing text and small files.
- Cookie based saved pastes and settings - Cookie based saved pastes and settings
- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant - Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
- Dark mode toggle in the topbar and settings, with a configurable default - Dark mode toggle in the topbar and settings, with a configurable default
- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars
## Screenshots ## Screenshots
| | | | | |
|---|---| |---|---|
| ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) | | ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) | | ![Paste view in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-midnight-dark.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Editor at mobile width](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png) |
Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
## Get Started ## Get Started
@@ -66,7 +66,7 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
+1 -7
View File
@@ -32,16 +32,10 @@ services:
# Default: 5242880 (5 MiB). # Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880" # PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can) or a # Max size in bytes of a single can item (file/text inside a can).
# paste file attachment.
# Default: 26214400 (25 MiB). # Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400" # PALETTE_MAX_ITEM: "26214400"
# Default dark mode for new visitors. Unset = dark on; set to "false",
# "0" or "off" to default to light mode. Visitors who toggle dark mode
# keep their choice in their browser.
# PALETTE_DEFAULT_DARK: "false"
# HMAC secret for password-unlock cookies. Default: random per start, # HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed # which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts, # secret (any random string) to keep unlock sessions across restarts,
+11
View File
@@ -91,6 +91,17 @@ func (l *limitReader) Read(p []byte) (int, error) {
return n, err return n, err
} }
// isImageMime reports whether the sniffed mime is a raster image the viewer
// can render inline (#221). SVG is excluded: it is forced to text/plain on
// serving by the active-content rule and must never render as an image.
func isImageMime(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
}
return false
}
// handleCreatePasteMultipart implements POST /api/pastes with // handleCreatePasteMultipart implements POST /api/pastes with
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file' // multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
// part makes the paste a file paste (1 file = 1 paste: if text content is // part makes the paste a file paste (1 file = 1 paste: if text content is
-61
View File
@@ -306,64 +306,3 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
t.Fatalf("paste should require password, got %d", rec2.Code) t.Fatalf("paste should require password, got %d", rec2.Code)
} }
} }
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
// not just raster images (the old isImageMime gate left non-image
// attachments serving an empty body from row.Content).
func TestRawStreamsNonImageAttachment(t *testing.T) {
s := testServer(t)
h := s.routes()
body := []byte("hello, this is a plain text attachment body")
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
if resp["attachment"] == nil {
t.Fatalf("no attachment in response: %v", resp)
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("nosniff = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
}
}
// #281: active-content attachment types still get forced to text/plain on
// /raw, same rule as the /f/ serving path (#34).
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
s := testServer(t)
h := s.routes()
html := []byte("<html><body><script>alert(1)</script></body></html>")
rec, resp := multipartCreate(t, h, "page.html", html, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), html) {
t.Fatal("raw bytes differ from upload")
}
}
-27
View File
@@ -1,27 +0,0 @@
// clientIP extracts the client IP for rate-limit keying.
//
// Trust boundary (issue #280): the bucket key MUST NOT come from any header a
// client can influence. The previous rightmost-X-Forwarded-For scheme (#85)
// assumed Traefik appends the real client IP, but the deployed ingress does
// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket
// per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates).
//
// The bucket key is the actual peer address (RemoteAddr) only. Behind any
// reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe. Client-supplied IP headers
// (X-Forwarded-For, X-Real-Ip) are never trusted.
package api
import (
"net"
"net/http"
)
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h
}
return host
}
+30
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -47,6 +48,35 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true return true
} }
// clientIP extracts the client IP for rate-limit keying (#85).
//
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
// proxy observed and is unspoofable by the client (a client-supplied fake
// entry only lands on the LEFT and is ignored). This matches chi's
// middleware.RealIP semantics for a single trusted proxy hop.
//
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
// reachable deployments must NOT expose the app to untrusted networks
// without a proxy in front, or attackers could forge the rightmost entry.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.LastIndex(xff, ","); i >= 0 {
return strings.TrimSpace(xff[i+1:])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter() var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings. // globalSettingsFn is set at startup; tests can point it at fixed settings.
+66 -31
View File
@@ -1,50 +1,85 @@
package api package api
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
// (appended by the trusted Traefik proxy), never the raw/leftmost header
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
// limit or rotate buckets.
import ( import (
"fmt" "bytes"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
) )
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { func TestClientIPTakesRightmostXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "203.0.113.7:4432" r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9") r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
}
}
func TestClientIPXRealIPFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000"
r.Header.Set("X-Real-Ip", "203.0.113.10") r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.7" { if got := clientIP(r); got != "203.0.113.10" {
t.Fatalf("clientIP = %q, want peer 203.0.113.7", got) t.Fatalf("clientIP = %q, want 203.0.113.10", got)
} }
} }
// A proxy-controlled header is not honored even when set: #280 revision func TestClientIPDirectFallback(t *testing.T) {
// removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the r := httptest.NewRequest("POST", "/", nil)
// bucket key is the peer address only. r.RemoteAddr = "198.51.100.5:51000"
func TestClientIPNeverTrustsHeaders(t *testing.T) { if got := clientIP(r); got != "198.51.100.5" {
r := httptest.NewRequest("POST", "/api/pastes", nil) t.Fatalf("clientIP = %q, want 198.51.100.5", got)
r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("clientIP = %q, want peer 10.0.1.47", got)
} }
} }
// Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest // TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
// repro was 8 creates with rotating XFF -> 6x201. // leftmost XFF entry stays limited on their real (rightmost) IP.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) { func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
globalLimiter = newLimiter() srv := newTestServer(t)
s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway h := srv.routes()
var allowed, limited int for i := 0; i < 5; i++ {
for i := 0; i < 8; i++ { req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
r := httptest.NewRequest("POST", "/api/pastes", nil) req.RemoteAddr = "10.42.0.7:51000"
r.RemoteAddr = "198.51.100.1:5000" // each request spoofs a DIFFERENT leftmost entry
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i)) req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
if rateLimitCreate(r, s) { rr := httptest.NewRecorder()
allowed++ h.ServeHTTP(rr, req)
} else { if rr.Code != 201 {
limited++ t.Fatalf("req %d: want 201, got %d", i, rr.Code)
} }
} }
if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) { // 6th request, still the same real IP, new spoofed prefix: must 429
t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst)) req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
}
}
func spoofN(i int) string {
return "1.2.3." + string(rune('0'+i))
}
// Distinct real IPs must still get distinct buckets (no over-limiting).
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
}
} }
} }
+4 -7
View File
@@ -496,18 +496,15 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404) http.Error(w, "not found", 404)
return return
} }
// #221: raw view of a paste backed by an attachment serves the stored // #221: raw view of an image paste serves the image bytes themselves as
// blob bytes with the sniffed mime, not the (empty) text content — for // an image, not the (empty) text content.
// ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) {
// too. serveContentType still forces active-content types (html, svg,
// xml) to text/plain per the #34 rule below.
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
blobs := a.store.Blobs() blobs := a.store.Blobs()
if blobs != nil { if blobs != nil {
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil { if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
defer blob.Close() defer blob.Close()
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95) a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Header().Set("Content-Type", serveContentType(att.Mime)) w.Header().Set("Content-Type", att.Mime)
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
+14 -43
View File
@@ -319,10 +319,8 @@ html[data-wrap] .float { overflow-x: hidden; }
.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; } .codebody { padding: 0 18px; white-space: pre; overflow-x: auto; }
/* #167: each logical line is its own block so offsetTop identifies its first visual row */ /* #167: each logical line is its own block so offsetTop identifies its first visual row */
.codeline { display: block; } .codeline { display: block; }
/* #167: gutter number spans must stack one per visual row (wrap on). /* #167 rev: gutter number spans must stack one per visual row (wrap on) */
#274: the /new editor gutter uses the same .gutline blocks when its own .code .gutter .gutline { display: block; }
wrap toggle is on, so scope the rule to any gutter, not just .code. */
.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; }
/* #194: codeline blocks are adjacent (no '\n' text between them), so an /* #194: codeline blocks are adjacent (no '\n' text between them), so an
empty block (blank source line) needs its own line box to stay one row */ empty block (blank source line) needs its own line box to stay one row */
.codeline:empty::before { content: "\200B"; } .codeline:empty::before { content: "\200B"; }
@@ -354,15 +352,10 @@ tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); } tr.row:hover td a.slug { color: var(--accent); }
/* #292: highlight the paste NAME uniformly on row hover, titled or not */
tr.row:hover td .paste-name { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.url-link { font-family: inherit; font-size: inherit; color: inherit; text-decoration: none; }
td a.url-link:hover { color: var(--accent); }
td a.slug:hover { color: var(--accent); } td a.slug:hover { color: var(--accent); }
/* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ /* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */
td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); }
td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; font-size: inherit; }
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
@@ -533,7 +526,7 @@ td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit;
/* paste name under slug pill in Paste column (#43) */ /* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); } .paste-sub.dim { color: var(--muted); }
td a.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } td a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; } td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); } td .id-link:hover { color: var(--accent); }
@@ -587,26 +580,20 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
outline-offset: 1px; outline-offset: 1px;
} }
/* swapbtn (#260): the label and the checkmark occupy the same grid cell, so
toggling feedback never changes the button's width — no pinning needed */
.swapbtn { display: inline-grid; }
.swapbtn > * { grid-area: 1 / 1; }
.swapbtn .btn-check { visibility: hidden; }
.swapbtn.ok .btn-check { visibility: visible; }
.swapbtn.ok .btn-label { visibility: hidden; }
/* in-place copy success feedback (#53) */ /* in-place copy success feedback (#53) */
.iconbtn.ok, .btn.ok { .iconbtn.ok, .btn.ok {
color: var(--ok); color: var(--ok);
border-color: var(--ok); border-color: var(--ok);
} }
/* #260: static-width success feedback. Label and checkmark stack in one
grid cell, so the button is always as wide as the wider of the two and
never shifts on click. Feedback is a pure .ok class toggle. */
.swapbtn {
display: inline-grid;
}
.swapbtn > * {
grid-area: 1 / 1;
justify-self: center;
}
.swapbtn .swap-check { visibility: hidden; }
.swapbtn.ok .swap-check { visibility: visible; }
.swapbtn.ok .swap-label { visibility: hidden; }
/* headings: unified treatment (mirrors .side-section h3) */ /* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 { .settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -894,11 +881,11 @@ button[type="submit"]:focus-visible,
.hidden { display: none; } .hidden { display: none; }
.can-page { max-width: 900px; width: 100%; } .can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; } .col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; }
.col-f { width: 100px; } .col-g { width: 120px; } .col-f { width: 100px; } .col-g { width: 190px; }
/* #255: history's URL column had its own narrow width (col-f doubles as /* #255: history's URL column had its own narrow width (col-f doubles as
/mine's ID column); give it a dedicated class. */ /mine's ID column); give it a dedicated class. */
.col-url { width: 230px; } .col-url { width: 150px; }
/* #210: /mine rows render a delete button cell that had no declared column, /* #210: /mine rows render a delete button cell that had no declared column,
so under table-layout:fixed it overlapped the ID column. */ so under table-layout:fixed it overlapped the ID column. */
.col-del { width: 64px; } .col-del { width: 64px; }
@@ -928,19 +915,3 @@ button[type="submit"]:focus-visible,
} }
.jumpnav.hidden { display: none; } .jumpnav.hidden { display: none; }
.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } .jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
/* #273: theme-aware scrollbars. Standard properties first (Firefox, and
Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer
Chromium styling. Colors come from CSS vars so they track the preset. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); }
::-webkit-scrollbar-corner { background: transparent; }
+2 -2
View File
@@ -6,12 +6,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`, `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.', emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.', emptyAll: 'No pastes yet. Create the first one.',
-10
View File
@@ -38,14 +38,4 @@
window.addEventListener('resize', refresh); window.addEventListener('resize', refresh);
if (scroller !== window && scroller) scroller.addEventListener('input', refresh); if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
refresh(); refresh();
/* #282: the first evaluation can run before the layout settles (media
queries, web fonts, async highlighting) and under-measure the content,
leaving the nav hidden on long pages. Re-check once a real layout exists
and after load; the ResizeObserver also catches late content growth. */
requestAnimationFrame(function () { requestAnimationFrame(refresh); });
window.addEventListener('load', refresh);
window.setTimeout(refresh, 300);
if (window.ResizeObserver && scroller === window && document.body) {
new ResizeObserver(refresh).observe(document.body);
}
})(); })();
+2 -2
View File
@@ -18,12 +18,12 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` + `<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td class="dim"><a class="url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) + (it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` + `<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`, `<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.', emptyFiltered: 'No pastes from this browser match your search.',
+5 -65
View File
@@ -2,71 +2,13 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter'); const content = $('content'), gutter = $('gutter');
// #274: with wrap on, a logical line occupies several VISUAL rows in the
// textarea, so one number per logical line drifts off its text (same bug the
// paste view fixed in #167). A textarea can't be split into spans, so the
// wrapped row count per logical line is measured with a hidden mirror div
// that shares the editor's font, line metrics and wrapping rules, and the
// gutter renders one .gutline block per visual row with the number on the
// FIRST row of its logical line (fillers elsewhere).
let mirror = null;
function measureRows(lines) {
if (!mirror) {
mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.top = '0';
mirror.style.left = '-9999px';
document.body.appendChild(mirror);
}
const cs = getComputedStyle(content);
mirror.style.font = cs.font;
mirror.style.lineHeight = cs.lineHeight;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'anywhere';
mirror.style.wordBreak = 'break-all';
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
const lh = parseFloat(cs.lineHeight) || 1;
const starts = [];
let total = 0;
const n = Math.max(lines.length, 1);
for (let i = 0; i < n; i++) {
// A trailing newline yields an empty last line: it still occupies one row.
mirror.textContent = lines[i] + '\n';
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
starts.push(total);
total += rows;
}
return { starts, total };
}
function updateGutter() { function updateGutter() {
const lines = content.value.split('\n'); const lines = content.value.split('\n').length;
const n = Math.max(lines.length, 1); let s = '';
if (!document.documentElement.hasAttribute('data-wrap')) { for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
let s = ''; gutter.textContent = s;
for (let i = 1; i <= n; i++) s += i + '\n';
gutter.textContent = s.slice(0, -1);
return;
}
const { starts, total } = measureRows(lines);
gutter.textContent = '';
const frag = document.createDocumentFragment();
const spans = [];
for (let r = 0; r < total; r++) {
const c = document.createElement('span');
c.className = 'gutline';
c.textContent = '\u00a0';
spans.push(c);
frag.appendChild(c);
}
gutter.appendChild(frag);
for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1);
} }
content.addEventListener('input', updateGutter); content.addEventListener('input', updateGutter);
// #274: the wrap toggle and width changes re-wrap the textarea; re-measure.
new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
window.addEventListener('resize', updateGutter);
// #259: the editor scrolls itself; keep the gutter's numbers in step with it. // #259: the editor scrolls itself; keep the gutter's numbers in step with it.
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
updateGutter(); updateGutter();
@@ -250,9 +192,7 @@ async function create() {
// button, password auto-unlock, then redirect to the paste. // button, password auto-unlock, then redirect to the paste.
function finishCreate(data) { function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id); const url = location.origin + '/' + (data.custom_slug || data.id);
// #260 attempt 2: .swapbtn markup — label and checkmark share one grid showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="btn-label">⧉</span><span class="btn-check">✓</span></button>', 'ok');
// cell, so the button width is static and feedback is a class toggle.
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="swap-label">Copy</span><span class="swap-check">✓</span></button>', 'ok');
$('result').dataset.token = data.deletion_token || ''; $('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy'); const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
+2 -3
View File
@@ -18,9 +18,8 @@ function toggleStats() {
} }
function copyFeedback(btn) { function copyFeedback(btn) {
if (!btn) return; if (!btn) return;
// #260 attempt 2: .swapbtn stacks the label and checkmark in the same grid // #260: the .swapbtn grid stacks label + checkmark in one cell, so the
// cell, so the button width is always the wider of the two and never moves. // width is static by construction — feedback is a pure class toggle.
// Feedback is a pure class toggle; no width pinning, no textContent swap.
btn.classList.add('ok'); btn.classList.add('ok');
clearTimeout(btn._okh); clearTimeout(btn._okh);
btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000); btn._okh = setTimeout(() => btn.classList.remove('ok'), 2000);
+1 -4
View File
@@ -73,10 +73,7 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; var dark = state().dark;
// #294: use the generic variant resolvers - midnight is dark-first document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
// (dark preset = 'midnight', light = 'midnight-light'), so a raw
// t.id + '-dark' build landed on nonexistent/forced-dark presets.
document.documentElement.dataset.preset = dark ? darkPreset(t.id) : lightPreset(t.id);
try { localStorage.setItem('palette-theme', t.id); } catch (e) {} try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-pressed', 'true');
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-url"><col class="col-f"><col class="col-del"></colgroup> <colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"><col class="col-del"></colgroup>
<thead><tr> <thead><tr>
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th> <th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th> <th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
+2 -2
View File
@@ -8,8 +8,8 @@
<div class="spacer"></div> <div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button> <button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">Wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">Raw</a> <a class="iconbtn" href="/raw/{{.ID}}">Raw</a>
<a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="swap-label">Link</span><span class="swap-check"></span></a> <a class="iconbtn swapbtn" href="#" id="copy-link-btn"><span class="btn-label">Link</span><span class="btn-check"></span></a>
<a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="swap-label">Copy</span><span class="swap-check"></span></a> <a class="iconbtn swapbtn" href="#" id="copy-btn"><span class="btn-label">Copy</span><span class="btn-check"></span></a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">Delete</a>{{end}} {{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">Delete</a>{{end}}
</div> </div>
</div> </div>