mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)
* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) Clicking a node now enters focus mode instead of navigating away: the camera flies to the node (800ms, standard distance-ratio pattern with at-origin guard), its neighborhood (any shared edge type) keeps full color while everything else dims to low-alpha, adjacent links brighten and the rest fade hard. A DetailCard slides in from the right: collection dot + ref + title, status pill (terminal styling), child count, relative updated-at, plus priority/assignee fetched lazily via the items API (stale-select token), and the "Open item" button carrying the old click-through navigation. Deselect via background click, Escape (only when a selection is active), or automatically when the workspace/show-completed payload changes. Selection sets stay plain non-reactive lets per CONVE-1688; accessor re-evaluation is explicit via graph.refresh(). Parent: PLAN-1730. * fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1) The force layout mutates link source/target from ref strings into node objects after ingest, so linkColor's adjacency check against selectedRef silently failed once the simulation ran. Preserve the raw refs as sourceRef/targetRef at mapping time and compare those.
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import type { NodeObject, LinkObject } from '3d-force-graph';
|
||||
import type { GraphResponse } from '$lib/types';
|
||||
import type { GraphResponse, Item } from '$lib/types';
|
||||
import DetailCard from './DetailCard.svelte';
|
||||
|
||||
let wsSlug = $derived(page.params.workspace ?? '');
|
||||
let username = $derived(page.params.username ?? '');
|
||||
@@ -44,6 +45,27 @@
|
||||
// Latches once the renderer is constructed; the data-sync effect waits on it.
|
||||
let rendererReady = $state(false);
|
||||
|
||||
// ── Focus / selection state (PLAN-1730 / TASK-1734) ──────────────────────────
|
||||
// The dim-everything-else highlight is driven by two plain `let` Sets that the
|
||||
// renderer accessor closures read. Per CONVE-1688 these stay non-reactive — they
|
||||
// are mutated imperatively in the click handler, never tracked by an $effect.
|
||||
// Re-evaluation is triggered explicitly by calling `graph.refresh()` after each
|
||||
// change (3d-force-graph README: `refresh()` "Redraws all the nodes/links",
|
||||
// re-running every color/opacity accessor).
|
||||
let selectedRef: string | null = null;
|
||||
let neighborRefs = new Set<string>();
|
||||
|
||||
// The selected node, surfaced to the detail-card template. A separate $state from
|
||||
// the plain Sets above: this one is READ in markup, so it must be reactive — but
|
||||
// no $effect both reads and writes it (it's only written from event handlers).
|
||||
let selectedNode = $state<GraphNode3D | null>(null);
|
||||
// Richer item detail, fetched lazily on select (priority / assignee live here).
|
||||
let selectedItem = $state<Item | null>(null);
|
||||
let selectedItemLoading = $state(false);
|
||||
// Stale-select token — same shape as reqSeq; gates which in-flight item fetch
|
||||
// commits so a fast re-select can't be clobbered by an older response.
|
||||
let selectSeq = 0;
|
||||
|
||||
// Node-count / edge-count readout for the toolbar.
|
||||
const nodeCount = $derived(graphData?.nodes.length ?? 0);
|
||||
const edgeCount = $derived(graphData?.edges.length ?? 0);
|
||||
@@ -118,11 +140,15 @@
|
||||
.nodeRelSize(4)
|
||||
// Subtree-weighted node size: parents/plans with children read bigger.
|
||||
.nodeVal((n: NodeObject) => 1 + (asNode(n).child_count ?? 0) * 2)
|
||||
.nodeColor((n: NodeObject) => colorForCollection(asNode(n).collection))
|
||||
// Selection-aware color: in focus mode, anything outside the selected
|
||||
// node's neighborhood is dimmed to a low-alpha version of its collection
|
||||
// color (the accessor reads the plain `let` Sets above).
|
||||
.nodeColor((n: NodeObject) => nodeColor(asNode(n)))
|
||||
.nodeLabel((n: NodeObject) => `${escapeHtml(asNode(n).ref)} — ${escapeHtml(asNode(n).name)}`)
|
||||
.nodeOpacity(0.95)
|
||||
// 'blocks' edges read red with a directional arrow; structural links
|
||||
// (parent/implements) brighter than soft links (wiki-link/related).
|
||||
// In focus mode, adjacent links brighten and the rest fade out.
|
||||
.linkColor((l: LinkObject<NodeObject>) => linkColor(asLink(l)))
|
||||
.linkOpacity(0.5)
|
||||
.linkWidth((l: LinkObject<NodeObject>) => (asLink(l).type === 'blocks' ? 1.5 : 0.5))
|
||||
@@ -130,13 +156,9 @@
|
||||
asLink(l).type === 'blocks' ? 3 : 0
|
||||
)
|
||||
.linkDirectionalArrowRelPos(1)
|
||||
.onNodeClick((n: NodeObject) => {
|
||||
const node = asNode(n);
|
||||
// Item pages live at [collection]/[slug]; the server's ResolveItem
|
||||
// resolves a PREFIX-NUMBER ref in the slug param (same path the
|
||||
// insights "What shipped" links use), so the ref works directly.
|
||||
void goto(`/${username}/${wsSlug}/${node.collection}/${node.ref}`);
|
||||
});
|
||||
.onNodeClick((n: NodeObject) => selectNode(asNode(n)))
|
||||
// Click empty space → exit focus mode (camera is left where it is).
|
||||
.onBackgroundClick(() => deselect());
|
||||
|
||||
graph = instance;
|
||||
rendererReady = true;
|
||||
@@ -161,9 +183,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection-aware accessors ────────────────────────────────────────────────
|
||||
// All three read the plain `let` selection Sets directly (CONVE-1688: no $state
|
||||
// in the imperative path). `graph.refresh()` re-runs them after each change.
|
||||
|
||||
// Node color: collection hex normally; dimmed (low-alpha) when a selection is
|
||||
// active and this node isn't in the neighborhood.
|
||||
function nodeColor(n: GraphNode3D): string {
|
||||
const base = colorForCollection(n.collection);
|
||||
if (selectedRef === null) return base;
|
||||
return neighborRefs.has(n.ref) ? base : hexToRgba(base, 0.15);
|
||||
}
|
||||
|
||||
// 'blocks' → red-ish; structural (parent/implements/supersedes/split-from) →
|
||||
// bright slate; soft (wiki-link/related) → dim slate. Alpha carries emphasis.
|
||||
// In focus mode: links touching the selected node brighten; the rest fade hard.
|
||||
function linkColor(l: GraphLink3D): string {
|
||||
if (selectedRef !== null) {
|
||||
// Compare against the preserved raw refs — the force layout mutates
|
||||
// source/target into node objects after ingest.
|
||||
const adjacent = l.sourceRef === selectedRef || l.targetRef === selectedRef;
|
||||
if (!adjacent) return 'rgba(148, 163, 184, 0.06)';
|
||||
if (l.type === 'blocks') return 'rgba(244, 63, 94, 0.95)';
|
||||
return 'rgba(148, 163, 184, 0.95)';
|
||||
}
|
||||
if (l.type === 'blocks') return 'rgba(244, 63, 94, 0.85)';
|
||||
if (l.type === 'parent' || l.type === 'implements' || l.type === 'supersedes' || l.type === 'split-from') {
|
||||
return 'rgba(148, 163, 184, 0.85)';
|
||||
@@ -171,12 +214,119 @@
|
||||
return 'rgba(148, 163, 184, 0.35)';
|
||||
}
|
||||
|
||||
// Hex (#rrggbb) → rgba() with the given alpha. The dim treatment for out-of-
|
||||
// neighborhood nodes; mixing toward transparent reads as receding into the
|
||||
// dark backdrop without losing the collection hue entirely.
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
// ── Selection / focus mode ───────────────────────────────────────────────────
|
||||
// After the linked-list edges resolve to node objects the renderer mutates
|
||||
// `source`/`target` from refs into the node instances; but our GraphLink3D still
|
||||
// carries the original ref strings via the raw payload. We compute neighborhoods
|
||||
// from `graphData.edges` (the source of truth, untouched by the renderer).
|
||||
function computeNeighbors(ref: string): Set<string> {
|
||||
const set = new Set<string>([ref]);
|
||||
const edges = graphData?.edges ?? [];
|
||||
for (const e of edges) {
|
||||
if (e.source === ref) set.add(e.target);
|
||||
else if (e.target === ref) set.add(e.source);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function selectNode(node: GraphNode3D) {
|
||||
// Plain-`let` selection state (consulted by the accessors).
|
||||
selectedRef = node.ref;
|
||||
neighborRefs = computeNeighbors(node.ref);
|
||||
// Reactive copy for the detail card.
|
||||
selectedNode = node;
|
||||
|
||||
// Camera fly-to: position the camera a comfortable distance out along the
|
||||
// node's position vector, looking at the node. Standard 3d-force-graph
|
||||
// pattern; guard the at-origin case where the vector has zero length.
|
||||
const dist = 60;
|
||||
const hyp = Math.hypot(node.x ?? 0, node.y ?? 0, node.z ?? 0);
|
||||
const ratio = hyp > 0 ? 1 + dist / hyp : 1;
|
||||
graph?.cameraPosition(
|
||||
{ x: (node.x ?? 0) * ratio, y: (node.y ?? 0) * ratio, z: (node.z ?? 0) * ratio },
|
||||
{ x: node.x ?? 0, y: node.y ?? 0, z: node.z ?? 0 },
|
||||
800
|
||||
);
|
||||
|
||||
// Re-run every node/link accessor so the dim/highlight takes effect.
|
||||
graph?.refresh();
|
||||
|
||||
// Fetch richer detail (priority / assignee) for the card. Stale-gated so a
|
||||
// rapid re-select can't be overwritten by an older response.
|
||||
void loadSelectedItem(node.ref);
|
||||
}
|
||||
|
||||
async function loadSelectedItem(ref: string) {
|
||||
const seq = ++selectSeq;
|
||||
selectedItem = null;
|
||||
selectedItemLoading = true;
|
||||
try {
|
||||
// Refs resolve server-side (same path the node click used to navigate to).
|
||||
const item = await api.items.get(wsSlug, ref);
|
||||
if (seq !== selectSeq) return;
|
||||
selectedItem = item;
|
||||
} catch {
|
||||
// Card degrades gracefully — it just won't show priority/assignee.
|
||||
if (seq !== selectSeq) return;
|
||||
selectedItem = null;
|
||||
} finally {
|
||||
if (seq === selectSeq) selectedItemLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear focus mode: un-dim everything, close the card. Does NOT move the camera
|
||||
// back (kept simple per TASK-1734). Bumps selectSeq so any in-flight item fetch
|
||||
// is discarded.
|
||||
function deselect() {
|
||||
if (selectedRef === null) return;
|
||||
selectedRef = null;
|
||||
neighborRefs = new Set<string>();
|
||||
selectedNode = null;
|
||||
selectedItem = null;
|
||||
selectedItemLoading = false;
|
||||
selectSeq++;
|
||||
graph?.refresh();
|
||||
}
|
||||
|
||||
// Open the selected item's page — this is where the old direct-click navigation
|
||||
// moved to. Item pages live at [collection]/[slug]; the server's ResolveItem
|
||||
// resolves a PREFIX-NUMBER ref in the slug param, so the ref works directly.
|
||||
function openSelected() {
|
||||
if (!selectedNode) return;
|
||||
void goto(`/${username}/${wsSlug}/${selectedNode.collection}/${selectedNode.ref}`);
|
||||
}
|
||||
|
||||
// Escape exits focus mode — but only when a node is selected, so it doesn't
|
||||
// swallow the key from other handlers (spirit of CONVE-639).
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && selectedRef !== null) {
|
||||
e.preventDefault();
|
||||
deselect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data load + sync ─────────────────────────────────────────────────────────
|
||||
// Fetch whenever the workspace or the "show completed" toggle changes, with a
|
||||
// request token so a stale response can't clobber a newer one.
|
||||
$effect(() => {
|
||||
const slug = wsSlug;
|
||||
const withTerminal = showCompleted;
|
||||
// Either trigger (workspace switch or show-completed toggle) yields a fresh
|
||||
// payload in which the selected node may no longer exist — clear focus mode
|
||||
// so the dim/highlight + detail card don't reference a vanished node. This
|
||||
// effect reads graphData-independent state only via deselect() (which never
|
||||
// reads graphData), so it stays CONVE-1688-clean.
|
||||
deselect();
|
||||
if (slug !== graphWsSlug) {
|
||||
graphWsSlug = slug;
|
||||
// Drop the previous workspace's graph so it doesn't linger under the new
|
||||
@@ -202,7 +352,17 @@
|
||||
graph.graphData({
|
||||
nodes: data ? data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })) : [],
|
||||
links: data
|
||||
? data.edges.map((e) => ({ source: e.source, target: e.target, type: e.type }))
|
||||
? // sourceRef/targetRef preserve the raw ref strings: after ingest the
|
||||
// force layout mutates source/target into node OBJECTS, so any
|
||||
// accessor comparing endpoints against a ref (linkColor's focus-mode
|
||||
// adjacency check) must read these instead (Codex PR #702 round 1).
|
||||
data.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceRef: e.source,
|
||||
targetRef: e.target,
|
||||
type: e.type
|
||||
}))
|
||||
: []
|
||||
});
|
||||
});
|
||||
@@ -245,14 +405,25 @@
|
||||
is_terminal: boolean;
|
||||
child_count: number;
|
||||
updated_at: string;
|
||||
// Position coords the renderer assigns as the force simulation runs. Present
|
||||
// on any node that's been laid out (always true by the time it's clicked).
|
||||
x?: number;
|
||||
y?: number;
|
||||
z?: number;
|
||||
}
|
||||
interface GraphLink3D {
|
||||
source: string;
|
||||
target: string;
|
||||
// source/target start as ref strings but are mutated into node objects by
|
||||
// the force layout after ingest — compare sourceRef/targetRef instead.
|
||||
source: string | NodeObject;
|
||||
target: string | NodeObject;
|
||||
sourceRef: string;
|
||||
targetRef: string;
|
||||
type: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="graph-page">
|
||||
<!-- Controls overlay (top-left) -->
|
||||
<div class="toolbar">
|
||||
@@ -270,6 +441,18 @@
|
||||
<!-- The renderer mounts here; it owns its own canvas. -->
|
||||
<div class="canvas" bind:this={containerEl}></div>
|
||||
|
||||
<!-- Focus-mode detail card (slides in from the right when a node is selected). -->
|
||||
{#if selectedNode}
|
||||
<DetailCard
|
||||
node={selectedNode}
|
||||
color={colorForCollection(selectedNode.collection)}
|
||||
item={selectedItem}
|
||||
itemLoading={selectedItemLoading}
|
||||
onopen={openSelected}
|
||||
onclose={deselect}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Overlay states (the canvas stays mounted underneath so the renderer keeps
|
||||
its WebGL context across reloads). -->
|
||||
{#if error}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
// Focus-mode detail card for the 3D workspace graph (PLAN-1730 / TASK-1734).
|
||||
//
|
||||
// Slides in from the right when a node is selected. The graph +page.svelte owns
|
||||
// selection state and the richer item fetch; this component is pure presentation
|
||||
// — it takes the selected node, the (optionally still-loading) full Item, and
|
||||
// emits open/close callbacks. Styling mirrors the page's .toolbar / .state-card
|
||||
// (backdrop-blur, color-mix surfaces) so the focus layer reads as the same UI.
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
import type { Item } from '$lib/types';
|
||||
|
||||
// The selected node's renderer-facing shape (a subset of the page's GraphNode3D).
|
||||
// Kept structural so the page can pass its mapped node straight through.
|
||||
interface SelectedNode {
|
||||
ref: string;
|
||||
title: string;
|
||||
collection: string;
|
||||
status?: string;
|
||||
is_terminal: boolean;
|
||||
child_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let {
|
||||
node,
|
||||
color,
|
||||
item,
|
||||
itemLoading,
|
||||
onopen,
|
||||
onclose
|
||||
}: {
|
||||
node: SelectedNode;
|
||||
/** collection color (hex) — shared with the node's renderer color. */
|
||||
color: string;
|
||||
/** full item, fetched lazily by the page; null until it arrives. */
|
||||
item: Item | null;
|
||||
itemLoading: boolean;
|
||||
onopen: () => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
// item.fields is a JSON string on the Item type. Parse defensively — handle a
|
||||
// pre-parsed object too (some callers/snapshots hydrate it eagerly) and never
|
||||
// throw on malformed JSON.
|
||||
const fields = $derived.by<Record<string, unknown>>(() => {
|
||||
const raw = item?.fields;
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'object') return raw as Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const priority = $derived(
|
||||
typeof fields.priority === 'string' && fields.priority ? fields.priority : null
|
||||
);
|
||||
const assignee = $derived(item?.assigned_user_name ?? null);
|
||||
</script>
|
||||
|
||||
<aside class="detail-card" aria-label="Selected item">
|
||||
<button class="close" onclick={onclose} aria-label="Close detail card">×</button>
|
||||
|
||||
<header class="card-head">
|
||||
<span class="dot" style:background-color={color} aria-hidden="true"></span>
|
||||
<span class="ref">{node.ref}</span>
|
||||
</header>
|
||||
|
||||
<h2 class="title">{node.title}</h2>
|
||||
|
||||
<div class="pills">
|
||||
{#if node.status}
|
||||
<span class="pill" class:terminal={node.is_terminal}>{node.status}</span>
|
||||
{/if}
|
||||
{#if node.child_count > 0}
|
||||
<span class="meta">{node.child_count} {node.child_count === 1 ? 'child' : 'children'}</span>
|
||||
{/if}
|
||||
<span class="meta">Updated {relativeTime(node.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Richer detail fetched separately; shows a shimmer line until it lands. -->
|
||||
<div class="detail-rows">
|
||||
{#if itemLoading}
|
||||
<div class="shimmer" aria-hidden="true"></div>
|
||||
{:else if item}
|
||||
{#if priority}
|
||||
<div class="row">
|
||||
<span class="row-key">Priority</span>
|
||||
<span class="row-val">{priority}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if assignee}
|
||||
<div class="row">
|
||||
<span class="row-key">Assignee</span>
|
||||
<span class="row-val">{assignee}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="open-btn" onclick={onopen}>Open item</button>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.detail-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-4);
|
||||
transform: translateY(-50%);
|
||||
z-index: 12;
|
||||
width: 18rem;
|
||||
max-width: calc(100% - var(--space-8));
|
||||
padding: var(--space-5);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
/* Slide in from the right. */
|
||||
animation: slide-in 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(12px, -50%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-right: 1.6rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
font-size: 1.02em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pill {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--text-secondary) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pill.terminal {
|
||||
color: var(--success, #10b981);
|
||||
background: color-mix(in srgb, var(--success, #10b981) 16%, transparent);
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.72em;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-rows {
|
||||
min-height: 1.2rem;
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0.15rem 0;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.row-key {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.row-val {
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
height: 0.85rem;
|
||||
width: 60%;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--text-muted) 10%, transparent) 25%,
|
||||
color-mix(in srgb, var(--text-muted) 22%, transparent) 50%,
|
||||
color-mix(in srgb, var(--text-muted) 10%, transparent) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.open-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--btn-primary-text, #fff);
|
||||
background: var(--accent, #6366f1);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.open-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user