feat(portfolio): complete tool, tests, widget, and share-gains flow (#2368)

* feat(portfolio): complete tool, tests, widget, and share-gains flow

Portfolio WASM tool with full pipeline:
- Indexer (fixture, dune, dune-replay backends)
- Analyzer (6 protocol classifiers, health extraction, stablecoin detection)
- Strategy filter (yield-floor, health-guard, LP impermanent-loss-watch)
- Intent builder (fixture + solver backends, bounded checks, leg bundling)
- Format (suggestion markdown, progress metric, widget state)

172 unit tests covering all modules including edge cases:
- filter.rs: 33 tests (yield floor, health guard, LP watch, helpers)
- bounded.rs: 16 tests (slippage, cost, chain allowlist, multi-leg)
- parser.rs: 18 tests (delimiters, YAML, kind inference, real strategies)
- fixture.rs: 14 tests (slippage calc, ID formats, payload structure)
- analyzer: 18 tests (stablecoin detection, health extraction, debt/yield)
- format.rs: 16 tests (totals, empty states, progress windowing)
- widget.rs: 10 tests (rendering, intents, non-ready filtering)
- types: 16 tests (parse_decimal, ChainSelector serde)
- 14 YAML replay scenarios + 4 live Dune API tests (ignored by default)

Share-gains feature:
- Gateway-level IronClaw.api.share() modal with X, LinkedIn, Facebook,
  copy-to-clipboard, and download buttons
- Portfolio widget generates SVG card showing gains (APY, annual savings,
  moves found) — no addresses or balances exposed
- "Share gains" button appears only when portfolio has positive delta

E2E Playwright tests (11 scenarios):
- Skill discovery via API and settings UI
- Chat integration (keyword + wallet address triggering)
- Widget rendering with pre-seeded state (positions, totals, suggestions)
- Share button visibility (present with gains, absent without)
- Share modal lifecycle (opens with card image, social buttons, closes)

Supporting changes:
- E2E conftest: SKILLS_DIR points to workspace skills/
- Mock LLM: canned responses for portfolio/defi and wallet address patterns
- Skill YAML, registry entry, capabilities JSON, 3 strategy docs, 4 scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address PR review — XSS, OnceLock, bounded checks, docs

Addresses review comments from #2368:

- XSS: widget renders all interpolated fields through escapeHtml();
  share modal creates <img> via DOM API with data:image/ prefix check
- OnceLock: protocol registry parsed once via std::sync::OnceLock
- to_ascii_lowercase() for wallet address lookups (fixture + dune_replay)
- bounded.rs: reject empty value_usd in single-leg slippage check
- fixture.rs: compute min_out amount and value_usd separately
- fixture.rs: clarify expires_at=0 comment (fixture = no expiry)
- schema.json: add "dune-replay" to source enum
- parser.rs: fix doc comment re kind inference (defaults, not inferred)
- live_tests.rs: fix log placeholder (raw_count vs classified.len())
- intent.rs: expand kind comment to match SCHEMA.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): escape remaining innerHTML fields, add tests, WASM build

- Escape delta_vs_last_run_usd and next_mission_run in widget innerHTML
- Add fixture test with amount != value_usd (stETH: 3.5 tokens / $12250)
  to verify the review fix separating amount from value_usd
- Add empty-legs test for bundling.rs order_legs
- Add comment explaining multi-leg empty value_usd tolerance in bounded.rs
- WASM component builds successfully (754K release binary)
  via: cargo component build --release --target wasm32-wasip2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address second-round PR review comments

- Tighten share image validation to data:image/png only (was data:image/*)
- Add ClipboardItem existence check to prevent runtime errors in some browsers
- Fix SCHEMA.md to correctly attribute invariant enforcement (bounded.rs vs bundling.rs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(portfolio): NEAR support end-to-end with engine v2 quality fixes

Add full NEAR Protocol support to the portfolio tool: scan via FastNEAR +
Intear, classify positions through new protocols (Linear, Meta Pool, Rhea
lending, Rhea LP), match against new NEAR-specific yield strategies, and
build intent bundles. Plus assorted infrastructure fixes uncovered while
exercising the v2 / CodeAct path.

Indexer
- New `near` source: FastNEAR `/v1/account/{id}/full` + Intear
  `/list-token-price` (235 KB, vs `/tokens` at 3.2 MB which exceeded fuel).
- New `near-replay` source for offline fixture replay.
- `auto` source dispatches per address: `0x...` → Dune, `*.near`/`*.tg` →
  NEAR backend. Mixed lists are split and merged.
- `classify_near_token()` tags known NEAR DeFi contracts (Linear, Meta
  Pool, Rhea/Burrow, Rhea/Ref) with proper `protocol_id`. Default for
  unknown FT contracts is `wallet`.
- Dust filter raised from \$0.01 → \$1 to keep wallets like `root.near`
  from passing 100+ micro-cap positions through the analyzer.
- Dune `value_usd` now accepts both string and number (Dune started
  returning floats).

Analyzer
- New protocols: `wallet`, `near-staking`, `linear`, `meta-pool`,
  `rhea-lending`, `rhea-lp`. Wallet positions are no longer silently
  dropped (the prior bug that made root.near show "meteor-private" only).

Strategies
- New `near-staking-yield`, `near-lending-yield`, `near-lp-yield` —
  match wallet/staking/LP positions on `chain == "near"`.
- `StrategyAppliesTo` gains `chains` and `tokens` filters.

Tool API
- `propose.strategies` is now optional → falls back to bundled defaults
  (3 EVM + 3 NEAR strategies).
- `propose.config` is now optional → falls back to `ProjectConfig::default()`.
- `build_intent.config` optional with default.
- `propose` recovers from stringified positions (common LLM mistake of
  calling `json.dumps()` first) and returns a clearer error message.
- Capability `dune_api_key` marked `optional: true` — NEAR-only and
  fixture flows no longer block on a missing Dune key.
- Default source is now `auto`.

WASM runtime
- Default fuel limit raised 10M → 500M across config, settings, channel
  runtime, and ResourceLimits. Production was using 10M (config path)
  while tests used `ResourceLimits::DEFAULT_FUEL_LIMIT` (was 100M) — the
  divergence masked the real fuel exhaustion. The 235 KB Intear parse
  uses ~27M fuel, so 500M provides ample headroom.
- Wrapper now logs fuel consumption at debug level for diagnostics.

Engine v2 / CodeAct UX
- Preamble: 3 new rules
  - Never reconstruct tool results manually — reference variables.
  - Never paste Python code outside `\`\`\`repl` or `FINAL(answer)`.
  - Chain tool calls in a single block.
  - Pass native Python objects to tools, never `json.dumps()` first.
- Postamble: explicit good/bad chaining example + `FINAL()` answer
  quality guidance (no terse counts).
- Orchestrator: when an action result exceeds 500 chars, the truncated
  preview now tells the LLM the full result is in `state['<tool>']`
  to discourage manual reconstruction.

Skill (`skills/portfolio/SKILL.md`)
- Step 4 (Propose): explicit anti-patterns for fabricated positions,
  strategy-name-only strings, and `floor_apy` percentage integers.
- Step 5 (Rank): allows informational LLM-only suggestions when
  `propose` returns no `ready` proposals.
- Step 6 (Build intents): explicit skip when no `ready` proposals;
  documents required `plan` shape (`legs`, `expected_out`,
  `expected_cost_usd`, `proposal_id`).
- Step 8 (Summarize): require detailed Markdown output, not counts.

Tests
- `tests/e2e_wasm_portfolio.rs` (5 tests): scan, propose, full pipeline
  via `TestRigBuilder` with canned HTTP — exercises real wasmtime sandbox
  with fuel metering.
- `tests/e2e_live_portfolio.rs` (2 tests, live-only via `IRONCLAW_LIVE_TEST=1`):
  end-to-end via `LiveTestHarness` against real LLM + real FastNEAR/Intear,
  with `engine_v2(true)`. Requires `--test-threads=1` due to a v2
  thread-registry race.
- Portfolio unit tests: 183 pass (added NEAR indexer parsers, dispatch
  auto-detection, new strategy filter cases).
- Live portfolio tests: 10 pass against real APIs.
- Updated `hostile/fake-token-dust` scenario for the new "wallet"
  protocol behaviour.

Bug fixes uncovered along the way
- `intents/bounded.rs`: epsilon raised to 0.005 to tolerate the 2-decimal
  truncation in `intents/fixture.rs` (intent bundles previously failed
  the slippage check on synthetic targets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address review findings from #2368

Correctness:
- bounded.rs: multi-leg slippage now checks the terminal leg (matching
  plan.expected_out.chain), not just single-leg bundles. Regression
  tests added for the bypass and for a multi-leg bundle with min_out=0
  on the terminal leg.
- bounded.rs: reject zero/negative/NaN/infinite expected_out (would
  make min_required = 0 and every leg pass vacuously).
- indexer/mod.rs: is_near_address now validates NEAR account rules
  (2..64 chars, lowercase, separators). Previously any non-0x string
  (empty, whitespace, emoji, SQL injection) passed.
- indexer/mod.rs: scan_auto rejects addresses that are neither valid
  EVM nor valid NEAR, instead of silently routing them to Dune.

Code quality:
- bundling.rs: replace .expect("indegree") and .expect("leg by id")
  with explicit error returns.
- fixture.rs: replace .unwrap() on plan.legs.last() with an Err path.
- types/mod.rs: pub use → pub(crate) use (crate-internal only).
- dune.rs / near.rs: warn (via host::log at Warn level) when a
  non-zero amount has a missing/zero value_usd, so silent undercounts
  surface in diagnostics rather than being invisible.

Security:
- gateway config.js: hoist the data:image/png prefix check to the top
  of IronClaw.api.share() so both img.src and a.href are gated.
- gateway config.js: add noopener,noreferrer to window.open features
  on share popups to close reverse-tabnabbing surface.
- widget/index.js: extend escapeXml to also escape apostrophes.

Infrastructure:
- limits.rs: TODO comment noting that 500M fuel default is driven by
  one tool (portfolio/near) and follow-up should add a per-tool
  override so the global default can stay tighter.
- test_portfolio.py: silent-return on missing widget tab converted to
  pytest.skip via shared _open_portfolio_tab_or_skip helper, so a
  regression that removes widget registration fails loudly instead of
  passing silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(portfolio): address follow-up review comments

- lib.rs: BuildIntent.solver now defaults to "fixture" (a valid
  value), not "auto" (unrecognized by intents::build — was shipping
  the default straight into an "Unknown intent solver: 'auto'" error
  whenever the caller omitted the field).
- capabilities.json: update discovery_summary to reflect that
  strategies/config on propose and config/solver on build_intent are
  optional. Stale text had propose requiring both positions and
  strategies.
- limits.rs + config/wasm.rs: fix the fuel-limit doc comments. The
  prior value in limits.rs was 100M (not 10M — that was the config
  path). Clarify both paths converged at 500M in #2368.
- config.js (share modal): add aria-label, aria-modal, role=dialog,
  aria-labelledby for the modal and explicit aria-label on every
  icon-only share button. Mark decorative SVGs aria-hidden. Toast
  becomes role=status with aria-live=polite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Illia Polosukhin
2026-04-20 14:47:51 +09:00
committed by GitHub
parent 0a8428fda1
commit 77e746f683
94 changed files with 13051 additions and 12 deletions

View File

@@ -181,8 +181,15 @@ def format_output(result, max_chars=8000):
if r.get("is_error"):
parts.append("[" + name + " ERROR] " + output)
else:
preview = output[:500] + "..." if len(output) > 500 else output
parts.append("[" + name + "] " + preview)
if len(output) > 500:
preview = output[:500] + "..."
parts.append(
"[" + name + "] " + preview +
"\n(full result stored in state['" + name + "']; "
"do NOT retype the data — reference the variable in your next call.)"
)
else:
parts.append("[" + name + "] " + output)
ret = result.get("return_value")
if ret is not None:

View File

@@ -9,10 +9,70 @@
Think step by step. Execute code immediately — don't just describe what you would do.
## Chaining tool calls — pass results forward by variable
When multiple tools need to run in sequence, chain them in one block and pass
previous results by **variable reference**, not by re-typing the data:
```repl
scan = await portfolio(action="scan", addresses=["root.near"], source="auto")
proposals = await portfolio(action="propose", positions=scan["positions"])
ready = [p for p in proposals["proposals"] if p["status"] == "ready"]
FINAL(f"Scanned {len(scan['positions'])} positions, {len(ready)} ready proposals")
```
**DO NOT** write this anti-pattern:
```repl
# WRONG: hand-typing positions from a previous tool call
positions = [
{"address": "root.near", "category": "wallet", "principal_usd": "5526.36", ...},
{"address": "root.near", "category": "liquid-staking", ...},
]
proposals = await portfolio(action="propose", positions=positions)
```
The scan already stored the positions in a variable. Just reference it.
## Error recovery
When a tool call fails, do NOT give up immediately. Try alternative approaches before calling FINAL():
When a tool call fails with `Invalid parameters: missing field X`, the fix is
almost always to reference the correct variable, not to hand-craft the data:
- If `propose` says "missing positions", use `scan['positions']` from a prior call.
- If `build_intent` says "missing plan", use `proposal['movement_plan']` from a prior propose.
- Do not "reconstruct" tool arguments from your understanding of the data —
the previous call already produced them as a Python object.
When a network tool fails with a real error (auth, 5xx, no results), try alternatives
before calling FINAL():
- If `http()` fails with an auth error, try `web_search()` or a different public endpoint
- If one API endpoint fails, try a different one that provides similar data
- If a search returns no results, try different keywords or broader queries
- Only call FINAL() to report failure after exhausting at least 2-3 alternative approaches
## Output discipline
Your response has exactly two useful forms:
1. A ```repl block that calls tools or calls `FINAL(answer)`.
2. Nothing else reaches the user except what you pass to `FINAL()`.
Do NOT write prose *about* the code ("Let me try a different approach", "I need
to pass the positions as a Python list") — prose outside a `FINAL()` answer is
noise that confuses the user. If you need to reason about what to do next, do
it silently and write code.
## FINAL() answer quality
The string you pass to `FINAL(answer)` is what the user sees. It must contain
the actual content they asked for — not a summary about it.
- BAD: `FINAL("Scan complete. 50 positions, 10 ready proposals.")`
- GOOD: `FINAL(f"## Portfolio\\n\\n{positions_table}\\n\\n## Top 3 Proposals\\n\\n{proposal_details}")`
If the user asked for yield opportunities, the answer must name specific
proposals with their APY, gain, and cost — not a count. Build up the answer
string with real data from tool results (`proposal["rationale"]`,
`proposal["projected_annual_gain_usd"]`, etc.), then call `FINAL()` once
with the complete Markdown.

View File

@@ -60,6 +60,18 @@ This is much faster than calling tools sequentially. Use `asyncio.gather()` when
7. For large data, process it in chunks using llm_query() on subsets rather than loading everything into context.
8. Outputs are truncated to 8000 chars — use variables to store large intermediate results.
9. Include the actual content in your FINAL() answer, not just a count or summary. Users want to see the details.
10. **Never reconstruct tool results manually.** Prior tool outputs are already Python objects — reference them via `state['<tool_name>']` or `state['last_return']` or by the variable name you stored them in. Writing `positions = [{"address": "...", ...}, ...]` with hardcoded data from a previous step is wrong — use the variable.
11. **Do not paste Python code into prose.** When you need to run code, put it in a ```repl block. When you need to explain something to the user, that explanation goes inside `FINAL(answer)` — NOT as free-form text followed by code. Mixing prose and code without a fence is the #1 source of bad responses.
12. **Chain tool calls in a single block.** If the task is scan → propose → build_intent, write one `repl` block that awaits all three in sequence, using the result of each as input to the next. Don't split across turns.
13. **Pass Python objects, NOT JSON strings.** Tool parameters accept native Python lists and dicts. NEVER call `json.dumps()` before passing a value. The tool harness serializes for you.
```python
# CORRECT — pass the list directly
await portfolio(action="propose", positions=scan["positions"])
# WRONG — passes a string literal; tool rejects with "expected a sequence"
await portfolio(action="propose", positions=json.dumps(scan["positions"]))
```
## Runtime environment

View File

@@ -88,6 +88,8 @@ pub const STYLE_CSS: &str = concat!(
"\n",
include_str!("../static/styles/components/markdown.css"),
"\n",
include_str!("../static/styles/components/share-modal.css"),
"\n",
include_str!("../static/styles/primitives/toast.css"),
"\n",
include_str!("../static/styles/surfaces/auth.css"),

View File

@@ -732,6 +732,133 @@ IronClaw.api = {
/** Navigate to a tab by ID. */
navigate: function(tabId) {
if (typeof switchTab === 'function') switchTab(tabId);
},
/**
* Open a share modal with social buttons.
*
* @param {Object} opts
* @param {string} opts.imageDataUrl - PNG data URL of the card image
* @param {string} opts.text - Pre-filled share text
* @param {string} [opts.hashtags] - Comma-separated hashtags (no #)
*/
share: function(opts) {
if (!opts || typeof opts.imageDataUrl !== 'string' ||
!opts.imageDataUrl.startsWith('data:image/png')) {
return;
}
var overlay = document.getElementById('share-modal-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'share-modal-overlay';
overlay.className = 'share-overlay';
overlay.innerHTML =
'<div class="share-modal" role="dialog" aria-modal="true" aria-labelledby="share-modal-title">' +
' <div class="share-header">' +
' <span class="share-title" id="share-modal-title">Share your gains</span>' +
' <button class="share-close" aria-label="Close share dialog">&times;</button>' +
' </div>' +
' <div class="share-preview"></div>' +
' <div class="share-actions">' +
' <button class="share-btn share-x" aria-label="Share on X" title="Share on X">' +
' <svg aria-hidden="true" focusable="false" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>' +
' </button>' +
' <button class="share-btn share-linkedin" aria-label="Share on LinkedIn" title="Share on LinkedIn">' +
' <svg aria-hidden="true" focusable="false" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>' +
' </button>' +
' <button class="share-btn share-facebook" aria-label="Share on Facebook" title="Share on Facebook">' +
' <svg aria-hidden="true" focusable="false" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>' +
' </button>' +
' <button class="share-btn share-copy" aria-label="Copy image to clipboard" title="Copy image">' +
' <svg aria-hidden="true" focusable="false" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>' +
' </button>' +
' <button class="share-btn share-download" aria-label="Download image" title="Download image">' +
' <svg aria-hidden="true" focusable="false" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' +
' </button>' +
' </div>' +
' <div class="share-toast" role="status" aria-live="polite"></div>' +
'</div>';
document.body.appendChild(overlay);
overlay.querySelector('.share-close').addEventListener('click', function() {
overlay.style.display = 'none';
});
overlay.addEventListener('click', function(e) {
if (e.target === overlay) overlay.style.display = 'none';
});
}
var text = opts.text || '';
var hashtags = opts.hashtags || 'DeFi,IronClaw';
var encodedText = encodeURIComponent(text);
var popupFeatures = 'noopener,noreferrer,width=550,height=';
var preview = overlay.querySelector('.share-preview');
preview.innerHTML = '';
var cardImg = document.createElement('img');
cardImg.className = 'share-card-img';
cardImg.alt = 'Share card';
cardImg.src = opts.imageDataUrl;
preview.appendChild(cardImg);
var toast = overlay.querySelector('.share-toast');
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('visible');
setTimeout(function() { toast.classList.remove('visible'); }, 2000);
}
var xBtn = overlay.querySelector('.share-x');
xBtn.onclick = function() {
window.open(
'https://twitter.com/intent/tweet?text=' + encodedText +
'&hashtags=' + encodeURIComponent(hashtags),
'_blank', popupFeatures + '420'
);
};
var liBtn = overlay.querySelector('.share-linkedin');
liBtn.onclick = function() {
window.open(
'https://www.linkedin.com/sharing/share-offsite/?mini=true&title=' + encodedText,
'_blank', popupFeatures + '520'
);
};
var fbBtn = overlay.querySelector('.share-facebook');
fbBtn.onclick = function() {
window.open(
'https://www.facebook.com/sharer/sharer.php?quote=' + encodedText,
'_blank', popupFeatures + '420'
);
};
var copyBtn = overlay.querySelector('.share-copy');
copyBtn.onclick = function() {
var img = preview.querySelector('img');
if (!img) return;
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
canvas.toBlob(function(blob) {
if (navigator.clipboard && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') {
try {
navigator.clipboard.write([new ClipboardItem({'image/png': blob})]).then(function() {
showToast('Image copied!');
}).catch(function() { showToast('Copy failed'); });
} catch (_) { showToast('Clipboard not supported'); }
} else {
showToast('Clipboard not supported');
}
}, 'image/png');
};
var dlBtn = overlay.querySelector('.share-download');
dlBtn.onclick = function() {
var a = document.createElement('a');
a.href = opts.imageDataUrl;
a.download = 'ironclaw-portfolio-gains.png';
a.click();
showToast('Downloaded!');
};
overlay.style.display = 'flex';
}
};

View File

@@ -0,0 +1,102 @@
/* Share modal */
.share-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.share-modal {
background: var(--bg-primary, #1a1a2e);
border: 1px solid var(--border, #333);
border-radius: 12px;
padding: 24px;
width: 420px;
max-width: 92vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.share-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.share-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #eee);
}
.share-close {
background: none;
border: none;
color: var(--text-muted, #888);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.share-close:hover {
color: var(--text-primary, #eee);
}
.share-preview {
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
background: #111;
}
.share-card-img {
display: block;
width: 100%;
height: auto;
}
.share-actions {
display: flex;
gap: 10px;
justify-content: center;
}
.share-btn {
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid var(--border, #333);
background: var(--bg-secondary, #222);
color: var(--text-primary, #ddd);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, transform 0.1s;
}
.share-btn:hover {
background: var(--bg-tertiary, #333);
transform: translateY(-1px);
}
.share-btn:active {
transform: translateY(0);
}
.share-x:hover { color: #1da1f2; }
.share-linkedin:hover { color: #0077b5; }
.share-facebook:hover { color: #1877f2; }
.share-toast {
text-align: center;
font-size: 13px;
color: var(--text-muted, #888);
margin-top: 12px;
height: 20px;
opacity: 0;
transition: opacity 0.2s;
}
.share-toast.visible {
opacity: 1;
}
@media (max-width: 768px) {
.share-overlay { align-items: flex-end; }
.share-modal {
width: 100%;
max-width: 100%;
border-radius: 12px 12px 0 0;
}
}