Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 47 additions & 11 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +22,51 @@ function Splash(): JSX.Element {
)
}

function AppRoutes({ onboarded }: { onboarded: boolean }): JSX.Element {
const { pathname } = useLocation()
const chatVisible = onboarded && pathname === '/'

return (
<div className="relative h-full w-full overflow-hidden bg-bg">
{/* 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 && (
<div
className={
chatVisible ? 'absolute inset-0' : 'pointer-events-none invisible absolute inset-0'
}
>
<Chat />
</div>
)}

{!chatVisible && (
<div className="absolute inset-0 z-10 bg-bg">
<Routes>
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/" element={onboarded ? null : <Navigate to="/onboarding" replace />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/skills" element={<Skills />} />
<Route path="/mcp" element={<Mcp />} />
<Route path="/themes" element={<Themes />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
)}
</div>
)
}

export default function App(): JSX.Element {
const ready = useRoxyStore((s) => s.ready)
const settings = useRoxyStore((s) => s.settings)
Expand All @@ -37,16 +82,7 @@ export default function App(): JSX.Element {

return (
<HashRouter>
<Routes>
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/" element={onboarded ? <Chat /> : <Navigate to="/onboarding" replace />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/skills" element={<Skills />} />
<Route path="/mcp" element={<Mcp />} />
<Route path="/themes" element={<Themes />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AppRoutes onboarded={onboarded} />
</HashRouter>
)
}
183 changes: 171 additions & 12 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down
20 changes: 15 additions & 5 deletions src/renderer/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</div>,
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/ContributionGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export function ContributionGraph({ data }: { data: ActivityStats }): JSX.Elemen
/>
{hover && (
<div
className="animate-fade-in pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap sq-frame sq-lg sq-fill-elevated sq-ring rounded-lg border border-border bg-elevated px-2 py-1 text-[11px] text-text shadow-xl"
className="animate-fade-in pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap sq-frame sq-lg sq-fill-elevated sq-ring edge edge-strong edge-panel rounded-lg border border-border bg-elevated px-2 py-1 text-[11px] text-text shadow-float"
style={{ left: hover.x, top: hover.y - 6 }}
>
<span className="font-semibold tabular-nums">{hover.day.count}</span>{' '}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/ImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export function ImagePreview({
<div
// pointer-events-none: the preview sits in the cursor's path, and a
// hover layer that can itself be hovered creates flicker loops.
className="animate-pop-in pointer-events-none fixed z-50 overflow-hidden sq-frame sq-xl sq-fill-elevated sq-ring sq-ring-strong rounded-xl border border-border-strong bg-elevated p-1 shadow-2xl"
className="animate-pop-in pointer-events-none fixed z-50 overflow-hidden sq-frame sq-xl sq-fill-elevated sq-ring sq-ring-strong edge edge-strong edge-panel rounded-xl border border-border-strong bg-elevated p-1 shadow-float"
style={{
left: placement.left,
top: placement.top,
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/components/InferenceControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const triggerClass =
* tall list into a scrolling one instead of one that runs off the top.
*/
const popoverClass =
'animate-pop-in absolute bottom-full z-50 mb-2 flex flex-col overflow-y-auto sq-frame sq-xl sq-fill-elevated sq-ring rounded-xl border border-border bg-elevated shadow-2xl origin-bottom-left'
'animate-pop-in absolute bottom-full z-50 mb-2 flex flex-col overflow-y-auto sq-frame sq-xl sq-fill-elevated sq-ring edge edge-strong edge-panel rounded-xl border border-border bg-elevated shadow-float origin-bottom-left'
/** Menu widths in px, matching what each picker renders. */
const POPOVER_W = 288
/** Just wide enough for "Build"/"Plan" + the check, now that blurbs are gone. */
Expand Down Expand Up @@ -470,7 +470,7 @@ export function ContextMeter(): JSX.Element {
>
{open && (
<div className="absolute bottom-full z-50 flex flex-col pb-1.5" style={anchor}>
<div className="animate-pop-in min-h-0 origin-bottom-left overflow-y-auto sq-frame sq-xl sq-fill-elevated sq-ring rounded-xl border border-border bg-elevated p-3 shadow-2xl">
<div className="animate-pop-in min-h-0 origin-bottom-left overflow-y-auto sq-frame sq-xl sq-fill-elevated sq-ring edge edge-strong edge-panel rounded-xl border border-border bg-elevated p-3 shadow-float">
<div className="mb-1.5 text-xs font-medium text-text">{t('inference.meterTitle')}</div>
<div className="mb-1 flex items-baseline justify-between text-[11px] text-text-subtle">
<span className="tabular-nums">
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/components/McpServers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpServerView['status'], string> = {
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'
}

Expand Down
Loading
Loading