diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index def79a8..c4ea14b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { HashRouter, Navigate, Route, Routes } from 'react-router-dom' +import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { useRoxyStore } from './lib/store' import roxy from './assets/roxy.png' import Onboarding from './routes/Onboarding' @@ -22,6 +22,51 @@ function Splash(): JSX.Element { ) } +function AppRoutes({ onboarded }: { onboarded: boolean }): JSX.Element { + const { pathname } = useLocation() + const chatVisible = onboarded && pathname === '/' + + return ( +
+ {/* Chat is the expensive screen: a cold mount rebuilds up to 30 markdown/tool + messages, the whole sidebar, two ResizeObservers, and the transcript's + scroll measurements before the first frame can paint. Settings used to + replace this tree, so its Back button was synchronous but still looked + frozen while all of that work ran again. + + Keep Chat mounted and laid out behind secondary routes instead. + `visibility:hidden` suppresses paint, hit-testing, focus and accessibility + exposure without `display:none`'s cold-layout penalty. Returning to `/` + is now only a layer reveal, and component-local state such as transcript + pagination and scroll position survives the trip. */} + {onboarded && ( +
+ +
+ )} + + {!chatVisible && ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ )} +
+ ) +} + export default function App(): JSX.Element { const ready = useRoxyStore((s) => s.ready) const settings = useRoxyStore((s) => s.settings) @@ -37,16 +82,7 @@ export default function App(): JSX.Element { return ( - - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - + ) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 26b73b5..f7d8d78 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -24,6 +24,30 @@ --color-warning: #d9a441; --color-danger: #f0556a; + /* Elevation. Stacked short shadows rather than one big blur: a real contact + shadow is tight and dark where the object meets the page and diffuses fast, + whereas a single `0 4px 12px` reads as a grey rectangle sitting behind the + element. `raised` is for things resting ON the page (buttons, cards); + `float` is for things detached from it (menus, modals), so it keeps the + tight contact layers and adds a longer throw. + + Declared in `@theme` rather than `:root` because that is what makes Tailwind + emit `shadow-raised` / `shadow-float` utilities; a plain custom property + would be readable but not usable as a class. + + Each is one level of INDIRECTION rather than a literal value, and that is + load-bearing. Tailwind resolves a `@theme` token at BUILD time and inlines + it into the utility, so `--shadow-raised: 0 1px ...` would compile to a + `box-shadow` with the numbers baked in -- unreachable by the light-theme + override below and by a theme file, both of which act at runtime. Pointing + the token at `var(--elevation-*)` keeps a live reference in the compiled + rule, so whatever `:root` says at runtime is what paints. (Verified against + the compiler: the utility emits `--tw-shadow: var(--elevation-raised)`.) + + The real values live in `:root`, below, next to the edge tokens. */ + --shadow-raised: var(--elevation-raised); + --shadow-float: var(--elevation-float); + /* Motion — the built-in CSS easings are too weak to feel intentional, so we register stronger custom curves as `ease-*` utilities (and :root vars). */ --ease-out-quart: cubic-bezier(0.23, 1, 0.32, 1); /* entrances: fast start, soft land */ @@ -264,6 +288,83 @@ --sq-r: calc(var(--radius-2xl) * var(--sq-scale)); } +/* --- Edge lighting ------------------------------------------------------- + * + * A hairline of one flat color is the honest way to draw a box, and it is why + * a dense dark UI can look correct and still feel cheap: every edge in it + * reflects light the same amount, which nothing in the physical world does. + * + * Two changes fix that, and they work together: + * + * 1. TRANSLUCENT, not opaque. `--color-border` is a fixed `#232326`, so it + * reads as a drawn line and it is wrong the moment the surface under it + * changes -- the same grey is too heavy on `bg` and too faint on `elevated`. + * A white at low alpha is a *sheen*: it takes on whatever it sits over, so + * one token is right on every surface, and it survives a light theme because + * `--color-white` is the polarity token (see shared/theme.ts), not literally + * white. + * + * 2. LIT FROM ABOVE. The top edge of a raised control catches more light than + * the bottom. `--sq-bevel` is that highlight, fading out over + * `--sq-bevel-span` (the worklet paints it as a second stroke over the ring). + * It is the cue that makes a button read as a physical object; without it a + * translucent hairline is just a fainter drawn line. + * + * The alphas are deliberately small. At these values you should not be able to + * point at the effect -- if the highlight is visible AS a highlight it has gone + * too far and the control starts to look embossed. */ +:root { + /* The resting hairline: a sheen over whatever surface it sits on. + + These alphas are low on purpose. An edge's job is to separate a control from + its background, and the moment you can point at it and call it "a border" it + has stopped doing that job and started competing with the content. The test + is that the shape stays legible while the line itself is not what you + notice. */ + --edge: color-mix(in srgb, var(--color-white) 5%, transparent); + /* Hover / focus, and the edge of anything that floats (menus, modals). */ + --edge-strong: color-mix(in srgb, var(--color-white) 9%, transparent); + /* The top-lit highlight for raised controls. Kept close to `--edge`: a hint of + direction, not a visible gleam. */ + --edge-lit: color-mix(in srgb, var(--color-white) 7%, transparent); + + /* Elevation, as the `shadow-raised` / `shadow-float` utilities read it. + + LITERAL black, deliberately not `var(--color-black)`. That token is half of + the polarity pair (see shared/theme.ts): it means "the color that sits ON + the primary button", and a light theme sets it to near-WHITE. Building a + shadow from it makes every light theme cast a white glow instead of a + shadow. Shadow is the absence of light -- it is dark in every theme, and + only its strength changes. + + Deliberately CONTACT shadows only -- tight, close and weak. A wide ambient + shadow is what makes a dark UI look like light-mockup cards pasted onto it: + against a near-black page the spread has nothing left to darken, so it reads + as a grey halo around the element rather than as depth. What actually sells + elevation here is the top-lit edge; the shadow only needs to anchor the + bottom. So `raised` is a single 1px contact line and `float` keeps just a + short throw for things genuinely detached from the page. */ + --elevation-raised: 0 1px 2px rgb(0 0 0 / 0.06); + --elevation-float: 0 1px 2px rgb(0 0 0 / 0.07), 0 4px 12px rgb(0 0 0 / 0.09); +} + +/* A light theme inverts the polarity tokens, so `--color-white` becomes + * near-black and the sheen above turns into a dark line -- correct in kind, but + * a *lit* top edge is a dark-UI idea: on paper the convincing cue is a soft + * shadow, not a highlight. So the bevel is dropped, the hairline is dialled back + * to something closer to a rule, and the shadows carry the depth instead. + * + * They are also softened: the same alpha that reads as a subtle contact shadow + * against a near-black page reads as dirt against white. */ +:root[data-appearance='light'] { + --edge: color-mix(in srgb, var(--color-white) 8%, transparent); + --edge-strong: color-mix(in srgb, var(--color-white) 14%, transparent); + --edge-lit: transparent; + + --elevation-raised: 0 1px 2px rgb(0 0 0 / 0.05); + --elevation-float: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.07); +} + /* The element keeps a real `border-radius`, just a different number. Zeroing it would be wrong in three ways: `box-shadow` follows `border-radius`, @@ -312,10 +413,27 @@ background-clip: border-box; } -/* The border still occupies layout -- only its painting moves to the worklet. */ +/* The border still occupies layout -- only its painting moves to the worklet. + * + * The default `--sq-ring` lives in the `base` LAYER while the rest of this rule + * stays unlayered, and that split is load-bearing. + * + * Unlayered CSS beats every `@layer`, which is exactly what we want for + * `border-color` (it must defeat the `border-border` utility) and exactly what + * we do NOT want for the fallback color: as one unlayered declaration it also + * beat `.sq-ring-strong` and every `hover:[--sq-ring:...]` in the app, so ~24 + * intended hover/focus/danger borders silently painted the default grey. The + * classes were written, compiled and shipped, and simply never applied. + * + * Splitting them lets each land where it belongs: `border-color` unlayered and + * unbeatable, the fallback in `base` where any utility can override it. */ [data-squircle] .sq-ring { border-color: transparent; - --sq-ring: var(--color-border); +} +@layer base { + [data-squircle] .sq-ring { + --sq-ring: var(--color-border); + } } /* The fill moves into the painted background, so the real one has to go or it @@ -355,16 +473,57 @@ border-style: solid; } -/* Ring colors for the minority of elements whose border isn't `--color-border`. - (Anything more exotic can set `[--sq-ring:...]` inline.) */ -.sq-ring-strong { - --sq-ring: var(--color-border-strong); -} -.sq-ring-accent { - --sq-ring: color-mix(in srgb, var(--color-accent) 35%, transparent); -} -.sq-ring-danger { - --sq-ring: color-mix(in srgb, var(--color-danger) 40%, transparent); +@layer components { + /* --- The edge treatment, as three classes ------------------------------- + * + * `.edge` is the whole effect: translucent hairline + top-lit bevel. It is + * an add-on to `.sq-ring`/`.sq-frame`, never a replacement -- those own the + * geometry, this only re-colors it. + * + * The span is set per size class rather than globally because the highlight + * has to die out over a fixed physical distance: ~1.5x the control's height + * keeps a 32px button lit across its whole face while a 300px card gets a lip + * along the top and stays flat below it. */ + .edge { + --sq-ring: var(--edge); + --sq-bevel: var(--edge-lit); + --sq-bevel-span: 48px; + } + /* Hover/active, and anything floating above the page. */ + .edge-strong { + --sq-ring: var(--edge-strong); + } + /* Panels: a lip along the top rather than a lit face. */ + .edge-panel { + --sq-bevel-span: 120px; + } + + /* Ring colors for the minority of elements whose border isn't `--color-border`. + (Anything more exotic can set `[--sq-ring:...]` inline.) + + These are MEANING, not decoration: a danger border is red because the thing + is destructive, and no amount of visual polish may quietly turn it grey. So + they are declared AFTER `.edge` deliberately. Both sit in the same layer at + the same specificity, which makes source order the tie-break, and an element + carrying `.edge` for its bevel plus `.sq-ring-danger` for its color has to + resolve to red -- otherwise adding the generic treatment to a popover + silently strips the semantic it was relying on. + + They stay in `components` (not unlayered) so a Tailwind utility in + `utilities` still wins: an element may carry `.sq-ring-danger` at rest and a + `hover:[--sq-ring:...]` on top, and the hover must take over. */ + .sq-ring-strong { + --sq-ring: var(--color-border-strong); + } + .sq-ring-accent { + --sq-ring: color-mix(in srgb, var(--color-accent) 35%, transparent); + } + .sq-ring-danger { + --sq-ring: color-mix(in srgb, var(--color-danger) 40%, transparent); + } + .sq-ring-success { + --sq-ring: color-mix(in srgb, var(--color-success) 40%, transparent); + } } /* Tailwind v4 borders default to currentColor — pin a sane default. */ diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index 7aa2f81..ec131c7 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -107,10 +107,20 @@ export function Composer({ // `sq-ring` repaints the border inside the squircle, so the color has to // travel as `--sq-ring` alongside each `border-*`. The drag ring is an // inset one so it follows the curve rather than boxing the corners. - className={`mx-auto max-w-3xl sq-frame sq-2xl sq-ring sq-fill-surface-2 rounded-2xl border bg-surface-2 transition ${ + // + // `edge` gives it the translucent, top-lit border, and `shadow-raised` + // -- not `float` -- because the composer is anchored to the bottom of + // the pane, not hovering over it. A float-weight shadow on a full-width + // element that never moves reads as a permanent dark band under the box + // rather than as depth. The edge already separates it from the + // conversation; the shadow only has to sit it down. + // + // On focus the hairline brightens rather than changing hue: the box is + // already the focus of the screen, so a colored ring on it is noise. + className={`mx-auto max-w-3xl sq-frame sq-2xl sq-ring sq-fill-surface-2 edge edge-panel shadow-raised rounded-2xl border bg-surface-2 transition ${ dragging ? 'border-accent [--sq-ring:var(--color-accent)] inset-ring-1 inset-ring-accent/40' - : 'border-border focus-within:border-border-strong focus-within:[--sq-ring:var(--color-border-strong)]' + : 'border-border focus-within:border-border-strong focus-within:[--sq-ring:var(--edge-strong)]' }`} > {images.length > 0 && ( @@ -147,9 +157,9 @@ export function Composer({ placeholder={ sending ? onStop - ? 'Queue a follow-up… (Esc to stop)' - : 'Queue a follow-up…' - : 'Ask Roxy anything… (paste or drop images)' + ? t('composer.queuePlaceholderStop') + : t('composer.queuePlaceholder') + : t('composer.placeholder') } onChange={(e) => { setValue(e.target.value) diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx index b72d9e8..38c1fad 100644 --- a/src/renderer/src/components/ContextMenu.tsx +++ b/src/renderer/src/components/ContextMenu.tsx @@ -99,7 +99,7 @@ export function ContextMenuSurface({ // Swallow the right-click so chording onto the menu doesn't reopen it at // a new point over itself. onContextMenu={(e) => e.preventDefault()} - className="animate-pop-in fixed z-50 overflow-hidden sq-frame sq-lg sq-fill-elevated sq-ring rounded-lg border border-border bg-elevated py-1 shadow-2xl" + className="animate-pop-in fixed z-50 overflow-hidden sq-frame sq-lg sq-fill-elevated sq-ring edge edge-strong edge-panel rounded-lg border border-border bg-elevated py-1 shadow-float" > {children} , diff --git a/src/renderer/src/components/ContributionGraph.tsx b/src/renderer/src/components/ContributionGraph.tsx index b3393c2..4c98fcd 100644 --- a/src/renderer/src/components/ContributionGraph.tsx +++ b/src/renderer/src/components/ContributionGraph.tsx @@ -379,7 +379,7 @@ export function ContributionGraph({ data }: { data: ActivityStats }): JSX.Elemen /> {hover && (
{hover.day.count}{' '} diff --git a/src/renderer/src/components/ImagePreview.tsx b/src/renderer/src/components/ImagePreview.tsx index 9f9ee52..8bf6f71 100644 --- a/src/renderer/src/components/ImagePreview.tsx +++ b/src/renderer/src/components/ImagePreview.tsx @@ -156,7 +156,7 @@ export function ImagePreview({
{open && (
-
+
{t('inference.meterTitle')}
diff --git a/src/renderer/src/components/McpServers.tsx b/src/renderer/src/components/McpServers.tsx index 9dbc79a..39c96be 100644 --- a/src/renderer/src/components/McpServers.tsx +++ b/src/renderer/src/components/McpServers.tsx @@ -16,9 +16,12 @@ function configSummary(config: McpServerConfig): string { return config.type === 'remote' ? config.url : config.command.join(' ') } +/* Badge's visible worklet hairline comes from `sq-ring-*`; the matching + `border-*` is still required as the graceful fallback when Paint Worklet is + unavailable or has not loaded yet. */ const MCP_STATUS_STYLES: Record = { - connected: 'border-success/30 bg-success/15 text-success', - error: 'border-danger/30 bg-danger/15 text-danger', + connected: 'border-success/30 sq-ring-success bg-success/15 text-success', + error: 'border-danger/30 sq-ring-danger bg-danger/15 text-danger', disabled: 'text-text-muted' } diff --git a/src/renderer/src/components/ModelPicker.tsx b/src/renderer/src/components/ModelPicker.tsx index b1976d6..88b48e1 100644 --- a/src/renderer/src/components/ModelPicker.tsx +++ b/src/renderer/src/components/ModelPicker.tsx @@ -336,7 +336,7 @@ export function ModelPicker(): JSX.Element { {open && (
diff --git a/src/renderer/src/components/ServicesSegment.tsx b/src/renderer/src/components/ServicesSegment.tsx index c5f2869..d53037c 100644 --- a/src/renderer/src/components/ServicesSegment.tsx +++ b/src/renderer/src/components/ServicesSegment.tsx @@ -200,7 +200,7 @@ function ServicesMenu({ // fixed `left-0` on a trigger this far right sends the menu off the window, // and there is no scroll container to rescue it.
-
+
{t('services.menuHeader')}
diff --git a/src/renderer/src/components/UsageMeter.tsx b/src/renderer/src/components/UsageMeter.tsx index cb3627d..416dca9 100644 --- a/src/renderer/src/components/UsageMeter.tsx +++ b/src/renderer/src/components/UsageMeter.tsx @@ -9,7 +9,6 @@ * provider. Cost is priced from the models.dev catalog at record time. */ import { useEffect, useMemo, useRef, useState } from 'react' -import { BarChart3, LayoutGrid } from 'lucide-react' import type { ProviderUsage, UsageDay, UsageStats } from '@shared/types' import { useTranslation, Trans } from 'react-i18next' import type { TFunction } from 'i18next' @@ -272,36 +271,33 @@ export function UsageMeter(): JSX.Element | null { onClick={() => setOpen(!open)} title={pillTitle} className={cn( - 'press-scale flex h-7 items-center gap-1.5 sq sq-lg sq-ring rounded-lg border px-2 text-xs tabular-nums transition-colors', + // No leading icon and so no `gap`: the value is already a currency + // amount, so the `$` says what it is. A chart glyph beside it was + // decoration competing with the number for the same job. + 'press-scale flex h-7 items-center sq sq-lg sq-ring edge rounded-lg border px-2 text-xs tabular-nums transition-colors', open - ? 'border-border-strong [--sq-ring:var(--color-border-strong)] bg-elevated text-text' - : 'border-border bg-surface text-text-muted hover:border-border-strong hover:[--sq-ring:var(--color-border-strong)] hover:text-text' + ? 'border-border-strong [--sq-ring:var(--edge-strong)] bg-elevated text-text' + : 'border-border bg-surface text-text-muted hover:border-border-strong hover:[--sq-ring:var(--edge-strong)] hover:text-text' )} > - {formatUsd(pillCost)} {open && ( -
- {/* Tabs */} -
- {tabs.map((t) => ( - - ))} +
+
+
{tab === 'overview' ? overviewPanel(usageStats, t) diff --git a/src/renderer/src/components/WorkstreamStrip.tsx b/src/renderer/src/components/WorkstreamStrip.tsx index 7e3db27..5bbfb76 100644 --- a/src/renderer/src/components/WorkstreamStrip.tsx +++ b/src/renderer/src/components/WorkstreamStrip.tsx @@ -312,7 +312,7 @@ function UnknownHostChip({ host }: { host: string }): JSX.Element {
{open && (
-
+
What does {host} run?
@@ -555,7 +555,7 @@ function ForgePanel({ return (
-
+
{view?.remote ? view.remote.slug : t('workstream.noRemote')} @@ -1069,7 +1069,7 @@ function BranchSegment({ {error && ( {error} @@ -1300,7 +1300,7 @@ function WorkstreamMenu({ {/* The whole menu scrolls, not just the branch list: a project with a dozen sessions makes the workstream list itself taller than the window, and `maxHeight` without `overflow` would only clip it differently. */} -
+
{t('workstream.menuWorkstreams')} {/* The default workstream is the project folder itself — always present, diff --git a/src/renderer/src/components/ui.tsx b/src/renderer/src/components/ui.tsx index ba2d3ad..413025b 100644 --- a/src/renderer/src/components/ui.tsx +++ b/src/renderer/src/components/ui.tsx @@ -15,13 +15,28 @@ type ButtonSize = 'sm' | 'md' // corners off a real border, so its hairline is repainted inside the shape. // `--sq-ring` has to state the color because a paint worklet can't read the // element's own `border-color`. +// +// `secondary` adds `.edge`, which swaps that flat hairline for a translucent one +// plus a top-lit bevel. Buttons deliberately carry no outer shadow: `.sq` masks +// the control to its squircle, which clips any box-shadow outside that shape. +// +// It keeps `border-border` as the pre-worklet fallback -- if the paint worklet +// never loads there is no bevel to speak of, and an opaque hairline is the right +// thing to degrade to. const BUTTON_VARIANTS: Record = { primary: 'bg-white text-black hover:bg-white/90', - secondary: 'bg-surface-2 text-text border border-border hover:bg-elevated sq-ring', + secondary: + 'bg-surface-2 text-text border border-border hover:bg-elevated sq-ring edge hover:[--sq-ring:var(--edge-strong)]', ghost: 'text-text-muted hover:text-text hover:bg-white/5', + // `danger` gets the same edge treatment as `secondary` -- it is the same KIND + // of control and should read as one, with only its color saying that it + // destroys something. `sq-ring-danger` is listed after `edge` on purpose: both + // live in `@layer components` at equal specificity, so source order decides, + // and main.css declares the semantic ring colors last precisely so the red + // survives the generic treatment. danger: - 'bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20 sq-ring [--sq-ring:color-mix(in_srgb,var(--color-danger)_30%,transparent)]' + 'bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20 sq-ring edge sq-ring-danger' } const BUTTON_SIZES: Record = { @@ -64,7 +79,13 @@ export const Input = forwardRef): J // `sq-frame`, not `sq`: a card is a container, and the mask `sq` applies clips // descendants the way `overflow-hidden` does. Painting the fill as this // element's own background instead leaves children (and any shadow) alone. + // + // `edge-panel` widens the bevel's falloff to 120px: on a tall panel the point + // is a lit lip along the top edge, not a gradient washing down the whole face. return (
diff --git a/src/renderer/src/lib/squircle-worklet.js b/src/renderer/src/lib/squircle-worklet.js index 5468672..59daa0e 100644 --- a/src/renderer/src/lib/squircle-worklet.js +++ b/src/renderer/src/lib/squircle-worklet.js @@ -115,6 +115,35 @@ function color(styleMap, name) { return value ? String(value).trim() : 'transparent' } +/** + * True when a color would actually put ink on the canvas. + * + * `transparent` computes to `rgba(0, 0, 0, 0)`, but a `color-mix()` landing on + * zero alpha serializes as `color(srgb 0 0 0 / 0)` instead, so comparing against + * the two spellings of "transparent" misses it and we pay for a stroke that + * paints nothing. Match the alpha component instead of the whole string. + */ +function opaque(value) { + if (!value || value === 'transparent' || value === 'none') return false + return !/(?:\/|,)\s*0(?:\.0*)?\s*\)$/.test(value) +} + +/** + * Blend two colors without parsing either. + * + * Canvas has no color API, but it accepts any CSS color *string*, and + * `color-mix()` is a CSS color. So the arithmetic can be handed to the engine, + * which also means it works for every form the Typed OM produces (`rgba()`, + * `color(srgb ...)`, `oklch()`) rather than just the ones a hand-written parser + * would cover. Chromium shipped `color-mix()` in 111; this app runs on 130. + * + * `srgb` specifically, not `oklab`: this mixes a translucent highlight into a + * translucent hairline, and sRGB is what the CSS tokens were authored against. + */ +function mix(a, b, percent) { + return `color-mix(in srgb, ${a} ${percent}%, ${b})` +} + /* `registerPaint` throws on a duplicate name, which happens when the dev server hot-reloads (the worklet scope outlives the page's JS). Swallowing it stops HMR from turning into an unhandled rejection that silently kills squircles. */ @@ -145,7 +174,15 @@ register( 'squircle-box', class { static get inputProperties() { - return ['--sq-r', '--sq-fill', '--sq-ring', '--sq-dash', 'border-top-width'] + return [ + '--sq-r', + '--sq-fill', + '--sq-ring', + '--sq-dash', + '--sq-bevel', + '--sq-bevel-span', + 'border-top-width' + ] } paint(ctx, size, styleMap) { const r = px(styleMap, '--sq-r', 12) @@ -153,7 +190,7 @@ register( // Skipped when `--sq-fill` is left at its `transparent` default, i.e. the // element is keeping its own `bg-*` and only wants the hairline. const fill = color(styleMap, '--sq-fill') - if (fill !== 'transparent' && fill !== 'rgba(0, 0, 0, 0)') { + if (opaque(fill)) { squirclePath(ctx, size.width, size.height, r, 0) ctx.fillStyle = fill ctx.fill() @@ -166,12 +203,65 @@ register( // lose its outer half and render at half opacity. squirclePath(ctx, size.width, size.height, r, w / 2) ctx.lineWidth = w - ctx.strokeStyle = color(styleMap, '--sq-ring') // `border-style: dashed` is painted by the UA along the *rectangle*, so its // dashes get sliced by the shape at every corner. `--sq-dash` re-creates it // along the squircle path instead. 0 (the default) means a solid stroke. const dash = px(styleMap, '--sq-dash', 0) if (dash > 0) ctx.setLineDash([dash, dash]) + + /* The edge is painted with exactly ONE stroke, and that is the whole + * trick. + * + * The obvious way to add a top-lit bevel is a second stroke over the same + * path. It looks right in the middle of a straight edge and wrong + * everywhere else, because the two strokes' anti-aliased coverage + * COMPOSITES: a boundary pixel the rasteriser gives 50% coverage gets + * painted twice and ends up far more opaque than either color asked for. + * On straight runs every pixel is either fully in or fully out, so nothing + * shows; around a corner almost every pixel is partial, so the corner + * silently darkens and the curve reads as chunky and stair-stepped. + * Measured on an `sq-lg` corner: peak alpha 26 -> 44, i.e. 69% brighter + * than the tokens specify, and the pixel-to-pixel roughness along the arc + * rose from 36 to 55. + * + * Blending the two colors FIRST and stroking once fixes it exactly: + * coverage is applied a single time, so anti-aliasing works as designed + * and the painted color is the one the CSS actually names. + * + * The gradient therefore runs from bevel-over-ring at the top edge to the + * plain ring by `--sq-bevel-span`, rather than from bevel to transparent. + * `--sq-bevel` is pre-mixed over `--sq-ring` at its own alpha so the top + * still reads as "ring plus highlight", which is what the two-stroke + * version was approximating before it over-applied it. + * + * The span is a length, not a percentage, on purpose: the highlight should + * die out over roughly the same physical distance on a 32px button as on a + * 300px card, the way real light does. A percentage would stretch it and + * make tall panels look uniformly frosted. */ + const ring = color(styleMap, '--sq-ring') + const bevel = color(styleMap, '--sq-bevel') + const ringVisible = opaque(ring) + const bevelVisible = opaque(bevel) + if (!ringVisible && !bevelVisible) return + + const span = px(styleMap, '--sq-bevel-span', 0) + // 0 means "no explicit span" -> fade across the whole element. + const end = span > 0 ? Math.min(span, size.height) : size.height + + if (!bevelVisible || end <= 0) { + // No highlight (a recessed control, or a light theme): a plain hairline. + ctx.strokeStyle = ring + } else { + const base = ringVisible ? ring : `rgb(from ${bevel} r g b / 0)` + const ramp = ctx.createLinearGradient(0, 0, 0, end) + // 60/40 rather than a flat over-composite: the highlight is strongest + // right at the lit edge and most of it is gone within the span, which is + // how a real surface falls off. Ending ON the ring color (not on + // transparent) is what keeps this a single stroke. + ramp.addColorStop(0, mix(bevel, base, 60)) + ramp.addColorStop(1, base) + ctx.strokeStyle = ramp + } ctx.stroke() } } diff --git a/src/renderer/src/lib/squircle.ts b/src/renderer/src/lib/squircle.ts index 2ba4b8e..376779c 100644 --- a/src/renderer/src/lib/squircle.ts +++ b/src/renderer/src/lib/squircle.ts @@ -19,12 +19,22 @@ import workletSource from './squircle-worklet.js?raw' * worklet receives a parsed `CSSUnitValue`/color instead of a raw token string, * and each has an initial value so an element that only sets `--sq-r` still gets a * sane ring color. + * + * Registration order is not significant, but the SET is: `paint()` only re-runs + * when a property listed in the painter's `inputProperties` changes, so anything + * added there has to be registered here or it will be read once and then go + * stale on hover. */ const PROPS: { name: string; syntax: string; initialValue: string }[] = [ { name: '--sq-r', syntax: '', initialValue: '8px' }, { name: '--sq-dash', syntax: '', initialValue: '0px' }, { name: '--sq-ring', syntax: '', initialValue: 'transparent' }, - { name: '--sq-fill', syntax: '', initialValue: 'transparent' } + { name: '--sq-fill', syntax: '', initialValue: 'transparent' }, + // The top-lit edge highlight, and how far down it fades. Registering the span + // as a `` (not a number) means the worklet receives it already + // resolved to px, and both animate because they are registered at all. + { name: '--sq-bevel', syntax: '', initialValue: 'transparent' }, + { name: '--sq-bevel-span', syntax: '', initialValue: '0px' } ] let started = false diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 12d3685..82f350f 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "إرفاق الصور", + "placeholder": "اسأل Roxy عن أي شيء… (الصق الصور أو أفلتها)", + "queuePlaceholder": "ضع متابعة في قائمة الانتظار…", + "queuePlaceholderStop": "ضع متابعة في قائمة الانتظار… (Esc للإيقاف)", "removeImage": "إزالة الصورة", "stop": "إيقاف (Esc)" }, diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index fdc0eec..1184c8a 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "Bilder anhängen", + "placeholder": "Frag Roxy alles… (Bilder einfügen oder ablegen)", + "queuePlaceholder": "Eine Folgeanfrage einreihen…", + "queuePlaceholderStop": "Eine Folgeanfrage einreihen… (Esc zum Stoppen)", "removeImage": "Bild entfernen", "stop": "Stopp (Esc)" }, diff --git a/src/renderer/src/locales/default.json b/src/renderer/src/locales/default.json index 16fd374..eb3dd1d 100644 --- a/src/renderer/src/locales/default.json +++ b/src/renderer/src/locales/default.json @@ -70,6 +70,9 @@ "composer": { "removeImage": "Remove image", "attachImages": "Attach images", + "placeholder": "Ask Roxy anything… (paste or drop images)", + "queuePlaceholder": "Queue a follow-up…", + "queuePlaceholderStop": "Queue a follow-up… (Esc to stop)", "stop": "Stop (Esc)" }, "configBackup": { diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 85cdecd..6f58af7 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "Adjuntar imágenes", + "placeholder": "Pregúntale lo que quieras a Roxy… (pega o suelta imágenes)", + "queuePlaceholder": "Pon un mensaje de seguimiento en cola…", + "queuePlaceholderStop": "Pon un mensaje de seguimiento en cola… (Esc para detener)", "removeImage": "Quitar la imagen", "stop": "Detener (Esc)" }, diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index d3879d7..f8b9e3d 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "Joindre des images", + "placeholder": "Demandez ce que vous voulez à Roxy… (collez ou déposez des images)", + "queuePlaceholder": "Mettre un message de suivi en file d’attente…", + "queuePlaceholderStop": "Mettre un message de suivi en file d’attente… (Échap pour arrêter)", "removeImage": "Supprimer l'image", "stop": "Arrêter (Échap)" }, diff --git a/src/renderer/src/locales/hi.json b/src/renderer/src/locales/hi.json index 1c06e40..4f4b7b8 100644 --- a/src/renderer/src/locales/hi.json +++ b/src/renderer/src/locales/hi.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "छवियाँ संलग्न करें", + "placeholder": "Roxy से कुछ भी पूछें… (चित्र चिपकाएँ या यहाँ छोड़ें)", + "queuePlaceholder": "अगला संदेश कतार में जोड़ें…", + "queuePlaceholderStop": "अगला संदेश कतार में जोड़ें… (रोकने के लिए Esc)", "removeImage": "छवि हटाएँ", "stop": "रोकें (Esc)" }, diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index 450cc6a..4566e60 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "画像を添付", + "placeholder": "Roxy に何でも質問…(画像を貼り付けるかドロップ)", + "queuePlaceholder": "次のメッセージをキューに追加…", + "queuePlaceholderStop": "次のメッセージをキューに追加…(Esc で停止)", "removeImage": "画像を削除", "stop": "停止 (Esc)" }, diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index c129205..aa867a7 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "Anexar imagens", + "placeholder": "Pergunte qualquer coisa à Roxy… (cole ou solte imagens)", + "queuePlaceholder": "Adicione uma continuação à fila…", + "queuePlaceholderStop": "Adicione uma continuação à fila… (Esc para parar)", "removeImage": "Remover imagem", "stop": "Parar (Esc)" }, diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 12eabfa..4f9722a 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "Прикрепить изображения", + "placeholder": "Спросите Roxy о чём угодно… (вставьте или перетащите изображения)", + "queuePlaceholder": "Добавить следующий запрос в очередь…", + "queuePlaceholderStop": "Добавить следующий запрос в очередь… (Esc — остановить)", "removeImage": "Удалить изображение", "stop": "Остановить (Esc)" }, diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 3ed4a9e..d766805 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -69,6 +69,9 @@ }, "composer": { "attachImages": "附加图片", + "placeholder": "问 Roxy 任何问题…(粘贴或拖放图片)", + "queuePlaceholder": "将后续消息加入队列…", + "queuePlaceholderStop": "将后续消息加入队列…(按 Esc 停止)", "removeImage": "移除图片", "stop": "停止 (Esc)" }, diff --git a/src/renderer/src/routes/Chat.tsx b/src/renderer/src/routes/Chat.tsx index 76b6e2a..535697b 100644 --- a/src/renderer/src/routes/Chat.tsx +++ b/src/renderer/src/routes/Chat.tsx @@ -1,7 +1,8 @@ +import { memo } from 'react' import { Sidebar } from '../components/Sidebar' import { ChatView } from '../components/ChatView' -export default function Chat(): JSX.Element { +function Chat(): JSX.Element { return (
@@ -9,3 +10,9 @@ export default function Chat(): JSX.Element {
) } + +/* App keeps this route mounted behind secondary screens. Without memo, every + location change would still walk the expensive transcript even though the + component instance survived; Chat has no props, so only its own store + subscriptions should make it render. */ +export default memo(Chat) diff --git a/src/shared/theme.ts b/src/shared/theme.ts index a67847d..ea9894d 100644 --- a/src/shared/theme.ts +++ b/src/shared/theme.ts @@ -190,6 +190,18 @@ const EXTRA_VAR_ALLOWLIST = new Set([ '--ease-drawer', // Corner geometry (see the squircle system in main.css) '--sq-scale', + // Edge lighting: the translucent hairline, its hover/float variant, and the + // top-lit bevel. These are derived from `--color-white` (the polarity token), + // so every theme already gets a coherent default -- these are here for a theme + // that wants a flatter or glassier look than the palette alone implies. + '--edge', + '--edge-strong', + '--edge-lit', + // Elevation. These are the indirection vars the `shadow-*` utilities read; + // the `--shadow-*` tokens themselves are compiled by Tailwind and cannot be + // re-pointed at runtime. + '--elevation-raised', + '--elevation-float', // Typography detail '--font-sans', '--font-mono', diff --git a/test/i18n.ts b/test/i18n.ts index f15cda0..e230d32 100644 --- a/test/i18n.ts +++ b/test/i18n.ts @@ -68,6 +68,10 @@ async function main(): Promise { ) check('es: accented text round-trips', t('settings.danger.wiping') === 'Borrando\u2026') check('es: the language section is translated', t('settings.language.heading') === 'Idioma') + check( + 'es: the composer placeholder is translated', + t('composer.placeholder') === 'Pregúntale lo que quieras a Roxy… (pega o suelta imágenes)' + ) // ---- The failure modes that matter ------------------------------------- // A key missing from Spanish must render English, never the raw key.