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.