mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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">×</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';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
102
crates/ironclaw_gateway/static/styles/components/share-modal.css
Normal file
102
crates/ironclaw_gateway/static/styles/components/share-modal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user