diff --git a/assets/brand/tokens.json b/assets/brand/tokens.json
index d58a7e3..1b621ca 100644
--- a/assets/brand/tokens.json
+++ b/assets/brand/tokens.json
@@ -1,10 +1,13 @@
{
- "brand_neon": "#67D5FF",
- "favicon_background": "#0C1017",
- "text_on_brand": "#062A38",
- "brand_text_on_light": "#006B87",
- "dark_background": "#0C1017",
- "dark_surface": "#101721",
- "dark_text": "#DCE7F5",
- "dark_muted_text": "#80A4C3"
+ "brand_neon": "#12F7D6",
+ "brand_secondary": "#00D1FF",
+ "electric_blue": "#007BFF",
+ "favicon_background": "#0B1320",
+ "text_on_brand": "#0B1320",
+ "brand_text_on_light": "#0058B8",
+ "dark_background": "#0B1320",
+ "dark_surface": "#0B1320",
+ "dark_text": "#F8FAFF",
+ "dark_muted_text": "#00D1FF",
+ "light_foreground": "#F8FAFF"
}
diff --git a/docs/assets/brand/favicon.svg b/docs/assets/brand/favicon.svg
index 1f4463e..17cd25a 100644
--- a/docs/assets/brand/favicon.svg
+++ b/docs/assets/brand/favicon.svg
@@ -1 +1 @@
-
+
diff --git a/docs/assets/brand/r10n.svg b/docs/assets/brand/r10n.svg
index 374e9f5..f9c29f7 100644
--- a/docs/assets/brand/r10n.svg
+++ b/docs/assets/brand/r10n.svg
@@ -1 +1 @@
-
+
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index 5fa177c..1e31c2c 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -1,18 +1,24 @@
:root {
- --r10n-neon: #67d5ff;
- --r10n-neon-on-light: #006b87;
- --r10n-dark-ink: #062a38;
- --r10n-dark-background: #0c1017;
- --r10n-dark-surface: #101721;
- --r10n-dark-text: #dce7f5;
- --r10n-dark-muted: #80a4c3;
+ --r10n-primary: #12f7d6;
+ --r10n-secondary: #00d1ff;
+ --r10n-electric-blue: #007bff;
+ --r10n-light-link: #0058b8;
+ --r10n-dark-ink: #0b1320;
+ --r10n-dark-background: #0b1320;
+ --r10n-light-background: #f8faff;
+ --r10n-dark-surface: #0b1320;
+ --r10n-dark-text: #f8faff;
+ --r10n-dark-muted: #00d1ff;
}
[data-md-color-scheme="default"] {
- --md-primary-fg-color: var(--r10n-neon-on-light);
- --md-accent-fg-color: var(--r10n-neon-on-light);
- --md-typeset-a-color: var(--r10n-neon-on-light);
- --r10n-focus: var(--r10n-neon-on-light);
+ --md-default-bg-color: var(--r10n-light-background);
+ --md-default-fg-color: var(--r10n-dark-ink);
+ --md-primary-fg-color: var(--r10n-dark-ink);
+ --md-primary-bg-color: var(--r10n-light-background);
+ --md-accent-fg-color: var(--r10n-electric-blue);
+ --md-typeset-a-color: var(--r10n-light-link);
+ --r10n-focus: var(--r10n-electric-blue);
}
[data-md-color-scheme="slate"] {
@@ -20,9 +26,9 @@
--md-default-fg-color: var(--r10n-dark-text);
--md-primary-fg-color: var(--r10n-dark-background);
--md-primary-bg-color: var(--r10n-dark-text);
- --md-accent-fg-color: var(--r10n-neon);
- --md-typeset-a-color: var(--r10n-neon);
- --r10n-focus: var(--r10n-neon);
+ --md-accent-fg-color: var(--r10n-primary);
+ --md-typeset-a-color: var(--r10n-secondary);
+ --r10n-focus: var(--r10n-primary);
}
.md-header__button.md-logo {
diff --git a/install.sh b/install.sh
index ee6a466..8a3357a 100755
--- a/install.sh
+++ b/install.sh
@@ -161,7 +161,7 @@ download_binary_asset() {
destination="$2"
if [ "${brand_displayed:-0}" -eq 0 ]; then
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${R10N_ACCESSIBLE:-}" != "1" ]; then
- printf '\033[96m' >&2
+ printf '\033[38;2;18;247;214m' >&2
fi
if [ "${R10N_ACCESSIBLE:-}" != "1" ]; then
# BEGIN GENERATED R10N ASCII
diff --git a/internal/brand/brand.go b/internal/brand/brand.go
index 1835d4d..9f4b5a2 100644
--- a/internal/brand/brand.go
+++ b/internal/brand/brand.go
@@ -10,10 +10,17 @@ import (
)
const (
- // Neon is the r10n wordmark's accessible foreground on dark surfaces.
- Neon = "#67D5FF"
- // LightAccent is its text/link variant for light backgrounds.
- LightAccent = "#006B87"
+ // Neon is r10n's primary foreground from the user-supplied palette.
+ Neon = "#12F7D6"
+ // Secondary is the palette's electric-cyan supporting accent.
+ Secondary = "#00D1FF"
+ // ElectricBlue is the palette's focus and active-control accent.
+ ElectricBlue = "#007BFF"
+ // DarkBackground and LightForeground are the palette's surface/text pair.
+ DarkBackground = "#0B1320"
+ LightForeground = "#F8FAFF"
+ // LightAccent is a contrast-safe text/link shade derived from ElectricBlue.
+ LightAccent = "#0058B8"
)
// Accent returns the configured terminal-safe accent or adapts to the terminal.
@@ -28,15 +35,27 @@ func Accent() lipgloss.TerminalColor {
}
}
+// FocusAccent returns a high-visibility control indicator for the current theme.
+func FocusAccent() lipgloss.TerminalColor {
+ switch os.Getenv("R10N_THEME") {
+ case "dark":
+ return lipgloss.Color(Neon)
+ case "light":
+ return lipgloss.Color(ElectricBlue)
+ default:
+ return lipgloss.AdaptiveColor{Light: ElectricBlue, Dark: Neon}
+ }
+}
+
// OnAccent returns readable text for a brand-colored button.
func OnAccent() lipgloss.TerminalColor {
switch os.Getenv("R10N_THEME") {
case "dark":
- return lipgloss.Color("#062A38")
+ return lipgloss.Color(DarkBackground)
case "light":
- return lipgloss.Color("#FFFFFF")
+ return lipgloss.Color(LightForeground)
default:
- return lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#062A38"}
+ return lipgloss.AdaptiveColor{Light: LightForeground, Dark: DarkBackground}
}
}
diff --git a/internal/brand/brand_test.go b/internal/brand/brand_test.go
index 908bc48..077820f 100644
--- a/internal/brand/brand_test.go
+++ b/internal/brand/brand_test.go
@@ -1,6 +1,7 @@
package brand
import (
+ "encoding/json"
"os"
"strings"
"testing"
@@ -21,3 +22,22 @@ func TestAccessibleBannerUsesReadableText(t *testing.T) {
t.Fatalf("accessible banner = %q", got)
}
}
+
+func TestAccentColorsMatchCanonicalTokens(t *testing.T) {
+ data, err := os.ReadFile("../../assets/brand/tokens.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var tokens map[string]string
+ if err := json.Unmarshal(data, &tokens); err != nil {
+ t.Fatal(err)
+ }
+ if tokens["brand_neon"] != Neon ||
+ tokens["brand_secondary"] != Secondary ||
+ tokens["electric_blue"] != ElectricBlue ||
+ tokens["dark_background"] != DarkBackground ||
+ tokens["light_foreground"] != LightForeground ||
+ tokens["brand_text_on_light"] != LightAccent {
+ t.Fatalf("terminal accents differ from canonical tokens: %#v", tokens)
+ }
+}
diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go
index c202379..1aa24ed 100644
--- a/internal/cli/upgrade.go
+++ b/internal/cli/upgrade.go
@@ -17,6 +17,7 @@ import (
"runtime"
"strconv"
"strings"
+ "sync/atomic"
"time"
"github.com/charmbracelet/huh"
@@ -28,8 +29,17 @@ import (
var releasesAPI = "https://api.github.com/repos/pruthivithejan/r10n/releases"
var upgradeHTTP = &http.Client{Timeout: 60 * time.Second}
+var upgradeDownloadHTTP = newStreamingDownloadClient()
var executablePath = os.Executable
+const downloadIdleTimeout = 2 * time.Minute
+
+func newStreamingDownloadClient() *http.Client {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.ResponseHeaderTimeout = 60 * time.Second
+ return &http.Client{Transport: transport}
+}
+
type release struct {
Tag string `json:"tag_name"`
Assets []struct {
@@ -142,14 +152,29 @@ func (r release) asset(name string) (string, int64, error) {
}
func download(ctx context.Context, address, destination string, limit, expectedSize int64, label string) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
+ downloadCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ var idleTimeout atomic.Bool
+ idleTimer := time.AfterFunc(downloadIdleTimeout, func() {
+ idleTimeout.Store(true)
+ cancel()
+ })
+ defer idleTimer.Stop()
+
+ req, err := http.NewRequestWithContext(downloadCtx, http.MethodGet, address, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "r10n-upgrade")
// Never send GITHUB_TOKEN to an asset host.
- res, err := upgradeHTTP.Do(req)
+ res, err := upgradeDownloadHTTP.Do(req)
if err != nil {
+ if idleTimeout.Load() {
+ return fmt.Errorf("download stalled before receiving %s", tui.SafeText(label))
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
return err
}
defer res.Body.Close()
@@ -177,6 +202,7 @@ func download(ctx context.Context, address, destination string, limit, expectedS
for {
read, readErr := res.Body.Read(buffer)
if read > 0 {
+ idleTimer.Reset(downloadIdleTimeout)
n += int64(read)
if n > limit {
_ = out.Close()
@@ -210,6 +236,16 @@ func download(ctx context.Context, address, destination string, limit, expectedS
if readErr != nil {
_ = out.Close()
_ = os.Remove(destination)
+ if idleTimeout.Load() {
+ return fmt.Errorf(
+ "download stalled while receiving %s for %s",
+ tui.SafeText(label),
+ downloadIdleTimeout,
+ )
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
return readErr
}
}
diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go
index 84e9647..92ee6ff 100644
--- a/internal/cli/upgrade_test.go
+++ b/internal/cli/upgrade_test.go
@@ -17,6 +17,7 @@ import (
"runtime"
"strings"
"testing"
+ "time"
)
type archiveEntry struct {
@@ -234,6 +235,43 @@ func TestReleaseAssetCarriesItsAdvertisedByteSize(t *testing.T) {
}
}
+func TestDownloadUsesStreamingTimeoutNotMetadataDeadline(t *testing.T) {
+ oldMetadataClient, oldDownloadClient := upgradeHTTP, upgradeDownloadHTTP
+ defer func() {
+ upgradeHTTP, upgradeDownloadHTTP = oldMetadataClient, oldDownloadClient
+ }()
+ upgradeHTTP = &http.Client{Timeout: 10 * time.Millisecond}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Length", "3")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Error("test server does not support streaming")
+ return
+ }
+ for _, chunk := range []byte("abc") {
+ if _, err := w.Write([]byte{chunk}); err != nil {
+ return
+ }
+ flusher.Flush()
+ time.Sleep(30 * time.Millisecond)
+ }
+ }))
+ defer server.Close()
+ upgradeDownloadHTTP = server.Client()
+ destination := filepath.Join(t.TempDir(), "asset")
+
+ if err := download(context.Background(), server.URL, destination, 1024, 3, "asset"); err != nil {
+ t.Fatal(err)
+ }
+ contents, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != "abc" {
+ t.Fatalf("downloaded bytes = %q, want %q", contents, "abc")
+ }
+}
+
func TestPlatformVersionChecksum(t *testing.T) {
for _, test := range []struct{ os, arch, want string }{
{"linux", "amd64", "r10n-linux-x86_64.tar.gz"}, {"darwin", "arm64", "r10n-macos-arm64.tar.gz"}, {"windows", "amd64", "r10n-windows-x86_64.exe"},
@@ -283,8 +321,12 @@ func TestUpgradeMetadataCheckAndInstall(t *testing.T) {
t.Skip(err)
}
_, native := installedFixture(t)
- oldExecutable, oldAPI, oldClient, oldOutput := executablePath, releasesAPI, upgradeHTTP, stdout
- defer func() { executablePath, releasesAPI, upgradeHTTP, stdout = oldExecutable, oldAPI, oldClient, oldOutput }()
+ oldExecutable, oldAPI, oldClient, oldDownloadClient, oldOutput :=
+ executablePath, releasesAPI, upgradeHTTP, upgradeDownloadHTTP, stdout
+ defer func() {
+ executablePath, releasesAPI, upgradeHTTP, upgradeDownloadHTTP, stdout =
+ oldExecutable, oldAPI, oldClient, oldDownloadClient, oldOutput
+ }()
executablePath = func() (string, error) { return native, nil }
stdout = io.Discard
t.Setenv("R10N_SOURCE", "")
@@ -317,6 +359,7 @@ func TestUpgradeMetadataCheckAndInstall(t *testing.T) {
defer server.Close()
releasesAPI = server.URL + "/releases"
upgradeHTTP = server.Client()
+ upgradeDownloadHTTP = server.Client()
if err := upgrade(context.Background(), []string{"--check", "--version", "99.0.0"}); err != nil {
t.Fatal(err)
}
diff --git a/internal/tui/forms.go b/internal/tui/forms.go
index fda9773..5076145 100644
--- a/internal/tui/forms.go
+++ b/internal/tui/forms.go
@@ -187,16 +187,17 @@ func runForm(ctx context.Context, form *huh.Form) error {
func theme() *huh.Theme {
colors := brand.Accent()
+ focus := brand.FocusAccent()
onAccent := brand.OnAccent()
t := huh.ThemeCharm()
- t.Focused.Base = t.Focused.Base.BorderForeground(colors)
+ t.Focused.Base = t.Focused.Base.BorderForeground(focus)
t.Focused.Title = t.Focused.Title.Foreground(colors).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(colors).Bold(true)
- t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(colors)
- t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(colors)
+ t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(focus)
+ t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(focus)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(colors)
- t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(colors)
- t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(colors)
+ t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(focus)
+ t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(focus)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(colors)
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(onAccent).Background(colors)
t.Group.Title = t.Focused.Title
diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py
index a864e90..288e408 100644
--- a/scripts/generate_brand_assets.py
+++ b/scripts/generate_brand_assets.py
@@ -79,7 +79,7 @@ def render_wordmark(lines: list[str], color: str) -> str:
'\n'
)
diff --git a/zensical.toml b/zensical.toml
index b0ab78a..b0a2cb6 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -54,7 +54,7 @@ repo = "fontawesome/brands/github"
# Light Mode (Default)
[[project.theme.palette]]
scheme = "default"
-primary = "light-blue"
+primary = "blue"
accent = "cyan"
toggle.icon = "material/brightness-7"
toggle.name = "Switch to dark mode"
@@ -63,7 +63,7 @@ toggle.name = "Switch to dark mode"
[[project.theme.palette]]
scheme = "slate"
primary = "cyan"
-accent = "light-blue"
+accent = "cyan"
toggle.icon = "material/brightness-4"
toggle.name = "Switch to light mode"