From 703fd02568841ab59f70be6d4e71391012e7be7e Mon Sep 17 00:00:00 2001 From: kostovster Date: Fri, 8 May 2026 17:21:57 +0300 Subject: [PATCH] feat(share-pnl): polish meta row, scale up PnL hero, localize timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the colored pill + arrow icon in favor of a compact meta row: small asset icon, then "Long" or "Short" colored by direction (palette positive tone for Long, negative for Short), separator, ticker. Frees vertical space and makes the PnL percent the dominant element of the card — sized up from 72/58 to 96/80 to match. Per-cover PnL palette so the percent and the direction word stay readable on every cover. The orange cover (#3) was the failure case under the prior shared brand red — the wine/forest tones it gets now have real contrast. Replace the manual "Timestamp: YYYY-MM-DD HH:MM" with localized formatting via Intl.DateTimeFormat using the active i18n locale and a 24-hour clock. Drops the "Timestamp:" label entirely; renders as e.g. "May 8, 2026 · 16:52". Force the Garet font into the document's font set up front via document.fonts.load(), called once and memoized. ctx.font assignment does not wait for @font-face loading, which was producing a fallback serif on the first paint after dialog open until the cover was switched. The memoized promise catches load rejections so a 404 or CSP block degrades to the system fallback rather than blanking every subsequent render. Remove now-orphaned i18n keys message.buy-position and message.timestamp from en.json — their only consumers were the deleted pill text and the old timestamp formatter. --- backend/config/locales/en.json | 2 - .../single-lease/SharePnLDialog.vue | 214 +++++++++--------- 2 files changed, 112 insertions(+), 104 deletions(-) diff --git a/backend/config/locales/en.json b/backend/config/locales/en.json index 8c5a7097..45eab80c 100644 --- a/backend/config/locales/en.json +++ b/backend/config/locales/en.json @@ -136,12 +136,10 @@ "protocol-revenue": "Protocol Revenue", "protocol-stats": "Protocol Stats", "share": "Share", - "buy-position": "Position", "share-position": "Share PnL", "lease-size": "Size", "opened-on": "Opened on", "download": "Download", - "timestamp": "Timestamp", "price-per-symbol": "Price per {symbol}", "optional-info-share": "Optional information to share", "pnl-amount": "PnL Amount", diff --git a/src/modules/leases/components/single-lease/SharePnLDialog.vue b/src/modules/leases/components/single-lease/SharePnLDialog.vue index 08ce5eaa..fa688a22 100644 --- a/src/modules/leases/components/single-lease/SharePnLDialog.vue +++ b/src/modules/leases/components/single-lease/SharePnLDialog.vue @@ -87,8 +87,6 @@ import { formatNumber } from "@/common/utils/NumberFormatUtils"; import { getCurrencyByTicker, getCurrencyByDenom } from "@/common/utils/CurrencyLookup"; import { useI18n } from "vue-i18n"; -import arrowup from "@/assets/icons/arrowup.svg?url"; -import arrowdown from "@/assets/icons/arrowdown.svg?url"; import shareImageOne from "@/assets/icons/share-image-1.svg?url"; import shareImageTwo from "@/assets/icons/share-image-2.png?url"; import shareImageThree from "@/assets/icons/share-image-3.png?url"; @@ -115,21 +113,25 @@ const showPositionSize = ref(true); type Palette = { text: string; muted: string; + pnlPositive: string; + pnlNegative: string; }; -// Per-cover text palette. PnL +/- and pill colors stay constant — the shared -// dark-blue/green/red set has enough contrast against every cover. +// Per-cover palette. Each cover gets its own positive / negative colors so the +// PnL hero and the Long/Short label remain readable against the illustration — +// the brand red on bright orange (cover 3) is the failure case the shared +// constants used to produce. const palettes: Palette[] = [ - { text: "#082D63", muted: "#5E7699" }, // 1: light grey/peach gradient - { text: "#FFFFFF", muted: "#C1CAD7" }, // 2: dark navy illustration - { text: "#082D63", muted: "#5E7699" }, // 3: orange - { text: "#082D63", muted: "#5E7699" } // 4: light lavender + // 1: light grey/peach gradient + { text: "#082D63", muted: "#5E7699", pnlPositive: "#1AB171", pnlNegative: "#AB1F3B" }, + // 2: dark navy — brighter tones to lift off the dark backdrop + { text: "#FFFFFF", muted: "#C1CAD7", pnlPositive: "#3FE490", pnlNegative: "#FF6577" }, + // 3: orange — deeper tones so red and green do not blend with the cover + { text: "#082D63", muted: "#5E7699", pnlPositive: "#0E5D3F", pnlNegative: "#5C0F1F" }, + // 4: light lavender + { text: "#082D63", muted: "#5E7699", pnlPositive: "#1AB171", pnlNegative: "#AB1F3B" } ]; -const PNL_POSITIVE = "#1AB171"; -const PNL_NEGATIVE = "#ab1f3b"; -const PILL_TEXT = "#FFFFFF"; - let leaseData: LeaseInfo | null; let leaseDisplayData: LeaseDisplayData | null; @@ -228,19 +230,45 @@ watch([showPnlAmount, showPrice, showPositionSize], () => { } }); +// Font preload. ctx.font = "..." picks the font synchronously but does not +// wait for the @font-face rule to load — the first canvas paint after dialog +// open silently falls back to the system serif/sans until the browser +// ambient-loads Garet via DOM use elsewhere. Awaiting document.fonts.load on +// every spec we paint forces the font into the document's font set up front. +// Memoized: subsequent calls resolve immediately. +let fontsReady: Promise | null = null; +const FONT_SPECS = [ + "500 24px 'Garet'", + "500 28px 'Garet'", + "500 32px 'Garet'", + "500 36px 'Garet'", + "600 80px 'Garet'", + "600 96px 'Garet'" +]; +const ensureFonts = (): Promise => { + if (fontsReady) return fontsReady; + if (typeof document === "undefined" || !document.fonts) { + fontsReady = Promise.resolve(); + return fontsReady; + } + // Catch the rejection so a font-load failure (404, CSP block, transient + // network) degrades to the system fallback. Without the catch a single + // failure poisons every render through the memoized promise. + fontsReady = Promise.all(FONT_SPECS.map((spec) => document.fonts.load(spec))) + .then(() => undefined) + .catch(() => undefined); + return fontsReady; +}; + const renderCard = async (ctx: CanvasRenderingContext2D, bgSrc: string) => { + await ensureFonts(); await setBackground(ctx, bgSrc); - const metric = await getBuyTextWidth(ctx); - drawPositionPill(ctx, metric); - - // setArrow + setAsset are awaited because they paint asynchronously via - // image.onload. Without await, a re-render triggered mid-flight (e.g. by the - // toggle watcher) lets a prior render's onload fire onto a newer frame's - // canvas, swapping in stale arrow direction / asset icon. - await setArrow(ctx); - setBuyText(ctx); - await setAsset(ctx); + // setAsset is awaited because it paints asynchronously via image.onload. + // Without await, a re-render triggered mid-flight (e.g. by the toggle + // watcher) lets a prior render's onload fire onto a newer frame's canvas, + // swapping in a stale asset icon. + await setPositionMetaRow(ctx); // PnL hero block (always shown). Inline absolute amount when toggle is on. setPnlPercent(ctx); @@ -249,7 +277,7 @@ const renderCard = async (ctx: CanvasRenderingContext2D, bgSrc: string) => { } // Optional rows stack below the PnL block. y-cursor advances per rendered row. - let cursorY = 555; + let cursorY = 540; if (showPrice.value) { setEntryPriceRow(ctx, cursorY); cursorY += 50; @@ -310,107 +338,88 @@ async function setBackground(ctx: CanvasRenderingContext2D, src: string) { }); } -function drawPositionPill(ctx: CanvasRenderingContext2D, textWidth: number) { - const radius = 20; - const x = 90; - const y = 230; - const width = textWidth + 100; - const height = 60; - - const fill = pnlNumber() < 0 ? PNL_NEGATIVE : PNL_POSITIVE; - ctx.strokeStyle = fill; - ctx.fillStyle = fill; - ctx.lineJoin = "round"; - ctx.lineWidth = radius; - - ctx.strokeRect(x + radius * 0.5, y + radius * 0.5, width - radius, height - radius); - ctx.fillRect(x + radius * 0.5, y + radius * 0.5, width - radius, height - radius); - ctx.stroke(); - ctx.fill(); -} +// Compact meta row at the top of the type column: +// [icon 40px] Long · BTC +// Direction word ("Long" / "Short") is colored by the palette's positive / +// negative tone — this is *position direction*, not PnL sign. The PnL hero +// below carries the sign coloring. Both can be the same red on a losing short +// without ambiguity because the size hierarchy makes the role obvious. +async function setPositionMetaRow(ctx: CanvasRenderingContext2D) { + const asst = asset(); + if (!asst) return; + + const positionType = leaseData ? configStore.getPositionType(leaseData.protocol) : "Long"; + const directionLabel = i18n.t(`message.${positionType.toLowerCase()}`); + const tickerLabel = asst.shortName ?? ""; + const pal = palette(); + const directionColor = positionType === "Short" ? pal.pnlNegative : pal.pnlPositive; -async function setArrow(ctx: CanvasRenderingContext2D) { const image = new Image(); - const data = await fetch(pnlNumber() < 0 ? arrowdown : arrowup); + const data = await fetch(asst.icon); const blob = await data.blob(); return new Promise((resolve) => { image.onload = () => { - ctx.drawImage(image, 110, 247, 28, 26); - resolve(); - }; - image.src = window.URL.createObjectURL(blob); - }); -} + const baselineY = 240; + const iconHeight = 40; + const iconScale = iconHeight / image.height; + const iconWidth = iconScale * image.width; + const iconX = 90; + const iconY = baselineY - iconHeight + 4; + ctx.drawImage(image, iconX, iconY, iconWidth, iconHeight); -async function getBuyTextWidth(ctx: CanvasRenderingContext2D) { - ctx.font = "600 30px 'Garet'"; - const posType = leaseData ? configStore.getPositionType(leaseData.protocol).toLowerCase() : "long"; - return ctx.measureText(`${i18n.t(`message.${posType}`)} ${i18n.t("message.buy-position")}`.toUpperCase()).width; -} + let cursorX = iconX + iconWidth + 18; -async function setBuyText(ctx: CanvasRenderingContext2D) { - ctx.font = "600 30px 'Garet'"; - ctx.fillStyle = PILL_TEXT; - const posType = leaseData ? configStore.getPositionType(leaseData.protocol).toLowerCase() : "long"; - ctx.fillText(`${i18n.t(`message.${posType}`)} ${i18n.t("message.buy-position")}`.toUpperCase(), 150, 270); -} + ctx.font = "500 36px 'Garet'"; + ctx.fillStyle = directionColor; + ctx.fillText(directionLabel, cursorX, baselineY); + cursorX += ctx.measureText(directionLabel).width; -async function setAsset(ctx: CanvasRenderingContext2D) { - const asst = asset(); - if (!asst) return; + const separator = " · "; + ctx.fillStyle = pal.muted; + ctx.fillText(separator, cursorX, baselineY); + cursorX += ctx.measureText(separator).width; - const image = new Image(); - const data = await fetch(asst.icon); - const blob = await data.blob(); - const textColor = palette().text; + ctx.fillStyle = pal.text; + ctx.fillText(tickerLabel, cursorX, baselineY); - return new Promise((resolve) => { - image.onload = () => { - const rect = 60; - const hf = rect / image.height; - const width = hf * image.width; - ctx.drawImage(image, 100, 310, width, hf * image.height); - - ctx.font = "500 42px 'Garet'"; - ctx.fillStyle = textColor; - if (asst.shortName) { - ctx.fillText(asst.shortName, 115 + width, 350); - } resolve(); }; image.src = window.URL.createObjectURL(blob); }); } +const PNL_HERO_BASELINE_Y = 430; + function setPnlPercent(ctx: CanvasRenderingContext2D) { const pos = pnlNumber(); const symbol = pos < 0 ? "-" : "+"; const [a, d] = Math.abs(pos).toFixed(2).split("."); + const pal = palette(); - ctx.fillStyle = pos < 0 ? PNL_NEGATIVE : PNL_POSITIVE; + ctx.fillStyle = pos < 0 ? pal.pnlNegative : pal.pnlPositive; const amount = `${symbol}${formatNumber(a, 0)}`; - ctx.font = "600 72px 'Garet'"; - ctx.fillText(amount, 90, 490); + ctx.font = "600 96px 'Garet'"; + ctx.fillText(amount, 90, PNL_HERO_BASELINE_Y); const w = ctx.measureText(amount).width; - ctx.font = "600 58px 'Garet'"; - ctx.fillText(`.${d}%`, 90 + w, 490); + ctx.font = "600 80px 'Garet'"; + ctx.fillText(`.${d}%`, 90 + w, PNL_HERO_BASELINE_Y); } function setPnlAmountInline(ctx: CanvasRenderingContext2D) { - ctx.font = "600 72px 'Garet'"; + ctx.font = "600 96px 'Garet'"; const pos = pnlNumber(); const symbol = pos < 0 ? "-" : "+"; const [a, d] = Math.abs(pos).toFixed(2).split("."); const integerWidth = ctx.measureText(`${symbol}${formatNumber(a, 0)}`).width; - ctx.font = "600 58px 'Garet'"; + ctx.font = "600 80px 'Garet'"; const decimalWidth = ctx.measureText(`.${d}%`).width; - ctx.font = "500 32px 'Garet'"; + ctx.font = "500 36px 'Garet'"; ctx.fillStyle = palette().muted; - ctx.fillText(`(${pnlAmountFormatted()})`, 90 + integerWidth + decimalWidth + 18, 485); + ctx.fillText(`(${pnlAmountFormatted()})`, 90 + integerWidth + decimalWidth + 22, PNL_HERO_BASELINE_Y - 8); } function setEntryPriceRow(ctx: CanvasRenderingContext2D, y: number) { @@ -450,21 +459,22 @@ function setPositionSizeRow(ctx: CanvasRenderingContext2D, y: number) { } function setTimeStamp(ctx: CanvasRenderingContext2D) { - const timestamp = new Date(); - const m = timestamp.getMonth() + 1; - const d = timestamp.getDate(); - const h = timestamp.getHours(); - const min = timestamp.getMinutes(); - - const year = timestamp.getFullYear(); - const month = m.toString().length == 1 ? `0${m}` : m; - const day = d.toString().length == 1 ? `0${d}` : d; - const hours = h.toString().length == 1 ? `0${h}` : h; - const minutes = min.toString().length == 1 ? `0${min}` : min; - - ctx.font = "500 32px 'Garet'"; + const now = new Date(); + const locale = i18n.locale.value; + const datePart = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric" + }).format(now); + const timePart = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(now); + + ctx.font = "500 28px 'Garet'"; ctx.fillStyle = palette().muted; - ctx.fillText(`${i18n.t("message.timestamp")}: ${year}-${month}-${day} ${hours}:${minutes}`, 90, 800); + ctx.fillText(`${datePart} · ${timePart}`, 90, 820); } function download() {