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 @@ -r10n R +r10n R 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 @@ -r10n routine automationNeon cyan block-letter r10n wordmark. +r10n routine automationElectric-blue block-letter r10n wordmark. 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: '' 'r10n routine automation' - 'Neon cyan block-letter r10n wordmark.' + 'Electric-blue block-letter r10n wordmark.' f'{rectangles}{strokes}\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"