Compare commits
7
Commits
v1.8.0-alpha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6672d978f7 | ||
|
|
a80712963c | ||
|
|
76e6f06995 | ||
|
|
b7ba35477c | ||
|
|
e87e8017bf | ||
|
|
6168f6a7d0 | ||
|
|
fdd26ba1e3 |
@@ -0,0 +1,75 @@
|
||||
app:
|
||||
id: alby-hub
|
||||
name: Alby Hub
|
||||
version: 1.23.0
|
||||
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
|
||||
category: money
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
- host: 8187
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/alby-hub
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- WORK_DIR=/data
|
||||
- PORT=8080
|
||||
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
|
||||
# advertised, so no extra port mapping is needed for payments to work.
|
||||
- LOG_LEVEL=info
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Alby Hub wallet interface
|
||||
type: ui
|
||||
port: 8187
|
||||
protocol: http
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/alby-hub.svg
|
||||
repo: https://github.com/getAlby/hub
|
||||
tier: optional
|
||||
launch:
|
||||
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
|
||||
# proxied responses. Nodes older than the gate fix show a blocked
|
||||
# frame — flip to true only if targeting such nodes.
|
||||
open_in_new_tab: false
|
||||
features:
|
||||
- Self-custodial Lightning node (LDK) with a friendly wallet UI
|
||||
- Connect wallets and apps via Nostr Wallet Connect (NWC)
|
||||
- Per-app budgets and isolated sub-wallets
|
||||
- Works with the Alby browser extension and mobile app
|
||||
@@ -0,0 +1,75 @@
|
||||
app:
|
||||
id: phoenixd
|
||||
name: phoenixd
|
||||
version: 0.9.0
|
||||
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
|
||||
category: money
|
||||
|
||||
container:
|
||||
# Image entrypoint already runs with --agree-to-terms-of-service and
|
||||
# --http-bind-ip 0.0.0.0, as user "phoenix"; no custom args needed.
|
||||
image: source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0
|
||||
pull_policy: if-not-present
|
||||
# The image runs as user phoenix (1000:1000); the datadir bind source
|
||||
# must be chowned to that identity or phoenixd dies on
|
||||
# "Failed to open /data/phoenix.conf with Permission denied".
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
- host: 9740
|
||||
container: 9740
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Loopback-only JSON API, not a web page. Every request is
|
||||
authenticated by the http password phoenixd generates in its own
|
||||
data directory on first run; the app gate's browser login page
|
||||
would break the API clients this port exists for.
|
||||
|
||||
volumes:
|
||||
# The wallet seed (seed.dat) and phoenix.conf live here. This directory
|
||||
# must survive reinstall/migration like any other app data dir —
|
||||
# losing it means losing funds.
|
||||
# Target is /data (via PHOENIX_DATADIR below), NOT the image's default
|
||||
# /phoenix/.phoenix: the orchestrator treats any bind path containing a
|
||||
# dot as a file mount and skips creating its source directory, so a
|
||||
# hidden-dir target never gets its host dir and the unit crash-loops.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/phoenixd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PHOENIX_DATADIR=/data
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:9740
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/phoenixd.svg
|
||||
repo: https://github.com/ACINQ/phoenixd
|
||||
tier: optional
|
||||
features:
|
||||
- Ultra-light Lightning node — no bitcoin node required
|
||||
- Automated channel and liquidity management (fees apply)
|
||||
- Simple HTTP API + websockets for payments
|
||||
- Backed by the team behind the Phoenix mobile wallet
|
||||
@@ -520,11 +520,63 @@ async fn proxy_to_app(
|
||||
|
||||
let client = hyper::Client::new();
|
||||
match client.request(Request::from_parts(parts, body)).await {
|
||||
Ok(resp) => resp,
|
||||
Ok(mut resp) => {
|
||||
neutralize_frame_blocking(resp.headers_mut());
|
||||
resp
|
||||
}
|
||||
Err(_) => app_down_page(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make gate-proxied app responses embeddable by the dashboard's My Apps
|
||||
/// iframe. Apps that were never designed for framing ship
|
||||
/// `X-Frame-Options: DENY` (Alby Hub) or a CSP `frame-ancestors` directive,
|
||||
/// and either one makes the embedded app session a dead grey pane — the
|
||||
/// historical workaround was a bespoke per-app nginx proxy (gitea), which is
|
||||
/// exactly the per-app patching the manifest platform exists to delete.
|
||||
///
|
||||
/// Framing protection exists to stop a FOREIGN origin from framing an authed
|
||||
/// page and clickjacking it. Behind the gate that threat model is already
|
||||
/// handled the way the gate's own pages handle it: every proxied request is
|
||||
/// authenticated by the gate first, and the gate's own responses declare
|
||||
/// `frame-ancestors 'self' http://*:* https://*:*` (see `page()`) because the
|
||||
/// dashboard is reached by LAN IP, mDNS name, and onion alike. Upstream
|
||||
/// X-Frame-Options is dropped entirely; only the `frame-ancestors` directive
|
||||
/// is removed from the app's CSP — the rest of the app's policy (script-src,
|
||||
/// connect-src, …) is the app's business and passes through untouched.
|
||||
fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
|
||||
headers.remove("x-frame-options");
|
||||
let Some(csp) = headers.get("content-security-policy") else {
|
||||
return;
|
||||
};
|
||||
let Ok(raw) = csp.to_str() else {
|
||||
return;
|
||||
};
|
||||
if !raw.to_ascii_lowercase().contains("frame-ancestors") {
|
||||
return;
|
||||
}
|
||||
let kept: Vec<&str> = raw
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|d| !d.to_ascii_lowercase().starts_with("frame-ancestors") && !d.is_empty())
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
headers.remove("content-security-policy");
|
||||
return;
|
||||
}
|
||||
match header::HeaderValue::from_str(&kept.join("; ")) {
|
||||
Ok(v) => {
|
||||
headers.insert("content-security-policy", v);
|
||||
}
|
||||
Err(_) => {
|
||||
// Unrepresentable after filtering — fail open for framing but
|
||||
// closed for the policy: better to drop a mangled CSP than to
|
||||
// serve one we rewrote incorrectly.
|
||||
headers.remove("content-security-policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie names owned by the gate/daemon, never the app's to see.
|
||||
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
|
||||
|
||||
@@ -1072,6 +1124,50 @@ mod tests {
|
||||
/// 2026-08-05). It must still be uncacheable, and still refuse to be
|
||||
/// framed by a foreign origin, which `frame-ancestors` expresses and
|
||||
/// `X-Frame-Options` cannot.
|
||||
/// Upstream frame-blocking must not survive the proxy: X-Frame-Options
|
||||
/// goes away entirely, CSP loses ONLY its frame-ancestors directive —
|
||||
/// the app's remaining policy must pass through byte-preserving in
|
||||
/// content (Alby Hub's DENY + strict CSP was the real-world case,
|
||||
/// archi-dev-box 2026-08-12).
|
||||
#[test]
|
||||
fn proxied_responses_lose_frame_blocking_but_keep_the_apps_csp() {
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; frame-ancestors 'none'; img-src 'self' https://cdn.example"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut headers);
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
let csp = headers["content-security-policy"].to_str().unwrap();
|
||||
assert!(!csp.contains("frame-ancestors"));
|
||||
assert!(csp.contains("default-src 'self'"));
|
||||
assert!(csp.contains("img-src 'self' https://cdn.example"));
|
||||
|
||||
// CSP that is ONLY a frame-ancestors directive disappears entirely.
|
||||
let mut only = hyper::HeaderMap::new();
|
||||
only.insert(
|
||||
"content-security-policy",
|
||||
"frame-ancestors 'self'".parse().unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut only);
|
||||
assert!(!only.contains_key("content-security-policy"));
|
||||
|
||||
// No frame directives at all → CSP untouched.
|
||||
let mut plain = hyper::HeaderMap::new();
|
||||
plain.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'".parse().unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut plain);
|
||||
assert_eq!(
|
||||
plain["content-security-policy"].to_str().unwrap(),
|
||||
"default-src 'self'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
|
||||
+98
-13
@@ -130,7 +130,38 @@ app:
|
||||
|
||||
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
|
||||
|
||||
Use `metadata.launch.open_in_new_tab: true` when the app UI is known to reject iframe embedding with headers such as `X-Frame-Options` or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.
|
||||
### Iframe embedding — the rules
|
||||
|
||||
The dashboard opens apps in an **embedded frame** (My Apps → app session) by
|
||||
default. Whether that works is decided by HTTP headers, not by wishes, so
|
||||
know the mechanics:
|
||||
|
||||
- Browsers refuse to render a page in an iframe when the response carries
|
||||
`X-Frame-Options: DENY`/`SAMEORIGIN` (the dashboard and the app are
|
||||
different origins — different port at minimum) or a CSP `frame-ancestors`
|
||||
directive that excludes the dashboard's origin.
|
||||
- Many upstream apps ship exactly those headers (Alby Hub sends
|
||||
`X-Frame-Options: DENY`). In a normal deployment that is correct hardening;
|
||||
behind Archipelago's app gate the clickjacking threat those headers address
|
||||
is already handled — every proxied request is authenticated by the gate
|
||||
first.
|
||||
- Therefore **the gate neutralizes frame blocking on proxied responses**: it
|
||||
removes `X-Frame-Options` and strips only the `frame-ancestors` directive
|
||||
from the app's CSP. The rest of the app's CSP (script-src, connect-src, …)
|
||||
passes through untouched — the gate never weakens the app's own content
|
||||
policy, only its framing policy. You do not need a bespoke reverse proxy,
|
||||
header patches, or app config to be embeddable.
|
||||
|
||||
Set `metadata.launch.open_in_new_tab: true` only when embedding is broken by
|
||||
things headers can't fix — the app frame-busts in JavaScript, requires being
|
||||
the top-level origin (OAuth redirect flows, WebAuthn), or sets
|
||||
`SameSite=Strict` session cookies that never accompany framed requests. Test
|
||||
in the real embedded app session, **not** a plain browser tab: tabs don't
|
||||
enforce framing headers, so a tab proves nothing about the iframe.
|
||||
|
||||
(Historical note: before the gate handled this, embeddable-but-blocking apps
|
||||
each carried a hand-built nginx strip proxy — gitea's port-3000 proxy is the
|
||||
surviving example. Do not copy that pattern for new apps.)
|
||||
|
||||
### Launch Interfaces
|
||||
|
||||
@@ -406,19 +437,73 @@ curl http://localhost:8180/health
|
||||
podman logs my-app
|
||||
```
|
||||
|
||||
### On an Archipelago Node
|
||||
### On an Archipelago Node (before your app is in the catalog)
|
||||
|
||||
1. Install via the marketplace UI or RPC:
|
||||
```bash
|
||||
curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
|
||||
-d '{"method":"package.install","params":{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}}'
|
||||
```
|
||||
2. Verify the container is running:
|
||||
```bash
|
||||
curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
|
||||
-d '{"method":"container-list"}'
|
||||
```
|
||||
3. Check the UI. The app's detail page is `http://archipelago.local/dashboard/apps/my-app`; the embedded launch surface is `http://archipelago.local/dashboard/app-session/my-app`
|
||||
The App Store lists **signed-catalog apps and Nostr-discovered apps only** —
|
||||
a manifest on the node's disk never appears in the store by itself. That is
|
||||
deliberate: the store is a trust surface. But the orchestrator installs from
|
||||
disk manifests just fine, so you can test the complete install/run/uninstall
|
||||
lifecycle on your own node before your app is published anywhere.
|
||||
|
||||
**1. Stage the manifest where it survives reboots.**
|
||||
|
||||
`/opt/archipelago/apps/` is *rebuilt on every backend start* from the runtime
|
||||
payload that ships inside the frontend bundle
|
||||
(`/opt/archipelago/web-ui/archipelago-runtime/apps/`). If you copy your
|
||||
manifest only into `/opt/archipelago/apps/`, the next restart silently deletes
|
||||
it. Stage into the payload directory instead — the boot sync then promotes it
|
||||
for you:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/archipelago/web-ui/archipelago-runtime/apps/my-app
|
||||
sudo cp apps/my-app/manifest.yml /opt/archipelago/web-ui/archipelago-runtime/apps/my-app/
|
||||
sudo systemctl restart archipelago # manifests are loaded at startup
|
||||
```
|
||||
|
||||
Watch `journalctl -u archipelago` after the restart — the orchestrator
|
||||
validates every manifest on load and tells you about problems immediately
|
||||
(for example a host-port collision with another installed app).
|
||||
|
||||
**2. Install over JSON-RPC.**
|
||||
|
||||
The repo ships the same session helper the release lifecycle gate uses.
|
||||
Three things to know before using it: it needs `jq`; it reuses a cached
|
||||
session from `/tmp/archy-rpc-session-<uid>` unless `ARCHY_FORCE_LOGIN=1` is
|
||||
set (a stale cache fails every call quietly); and it sets `set -euo pipefail`,
|
||||
so run it inside a script or subshell — sourcing it into your interactive
|
||||
shell makes the first failed step kill the whole chain without printing
|
||||
anything.
|
||||
|
||||
```bash
|
||||
bash <<'EOF'
|
||||
export ARCHY_PASSWORD='<your dashboard password>' ARCHY_FORCE_LOGIN=1
|
||||
# Stock nodes serve HTTPS on 443; dev boxes behind plain nginx use:
|
||||
# export ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http
|
||||
source tests/lifecycle/lib/rpc.bash
|
||||
rpc_login && echo "login ok"
|
||||
# Both fields are required: `dockerImage` is normally supplied by the App
|
||||
# Store from the signed catalog — pre-catalog, you pass your manifest's
|
||||
# image yourself (it must match, and must come from a trusted registry).
|
||||
rpc_call package.install '{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}'
|
||||
EOF
|
||||
```
|
||||
|
||||
**3. Verify the lifecycle, not just the install:**
|
||||
|
||||
```bash
|
||||
rpc_call package.status '{"id":"my-app"}' # state + health (run inside the same subshell pattern)
|
||||
podman ps --filter name=my-app # container is up
|
||||
rpc_call package.stop '{"id":"my-app"}' # …and start, restart
|
||||
sudo systemctl restart archipelago # app must survive this
|
||||
rpc_call package.uninstall '{"id":"my-app","preserve_data":true}'
|
||||
rpc_call package.install '{"id":"my-app"}' # data still there?
|
||||
```
|
||||
|
||||
The app's detail page is `https://<node>/dashboard/apps/my-app`; a gated web
|
||||
UI is reachable through the app gate on its manifest port once running.
|
||||
|
||||
Only after this loop is green does the app belong in a catalog submission —
|
||||
catalog inclusion is what makes it appear in the App Store.
|
||||
|
||||
### Validate Manifest
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8000 8000">
|
||||
<path d="M1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934ZM1398.36987,2392.55825c.01.02001.01999.03996.01999.04996.01999.02001.03998.04996.04998.06997-.01999-.03996-.04998-.07997-.06997-.11993ZM7957.22848,6407.80899c-75.3022-453.82005-341.31246-1051.68618-631.76608-1536.83905-217.58172,280.41299-443.70125,543.12568-695.66263,796.78364,151.96083,272.22676,381.52012,831.33939,371.78586,1131.07073-3.34955,103.13742-84.59871,186.38404-187.48323,193.03587-323.69068,20.92769-761.15088-169.80941-1126.96417-352.01125-274.08918,250.36976-539.73074,478.97192-815.02811,690.20321,570.85548,338.56749,1305.54247,669.74202,1904.46573,668.08293,872.77622,46.26671,1378.68446-776.20339,1180.65263-1590.32607ZM5249.15884,5683.56479c1101.91285-1101.01171,2353.03599-2527.31198,2708.11113-4090.5697,253.93721-1273.80049-760.34667-1869.98142-1895.16006-1469.66567,211.65045,278.23428,364.22738,589.33979,457.73054,914.98744,86.80988-20.20216,196.93112-34.80914,289.99476-31.50156,103.33929,3.67279,186.93541,86.38156,191.60732,189.77353,13.96581,309.07109-235.73659,902.8025-402.1382,1193.8406,0,.01-.01.02001-.01.03001l-.04998.07991c-624.44753,1143.09071-1591.89389,2208.42396-2600.29743,3077.06633l-.22964-.19984c-263.64443,226.46298-526.16056,433.98687-804.2439,626.56893.39938.34966.79882.69956,1.19826,1.04946-509.31622,353.04862-1419.46673,925.33606-2012.13405,893.44541-103.82534-5.58671-186.23795-90.1864-188.67838-194.22674-6.97909-297.53161,215.6413-843.71786,363.83869-1111.11806-237.05325-259.94423-464.71032-529.69327-686.31643-814.38364-1228.33988,1900.32534-772.83666,3911.20697,1772.96127,2814.86279,1004.95316-454.63593,1968.00538-1208.05697,2803.81609-2000.0392ZM2528.2177,4000.38756c-468.17105-551.20508-806.89093-1020.88409-1125.16461-1599.13417h-.01c-.00197-.02709-.02111-.04517-.02999-.07997h-.01c-.00225-.03154-.04191-.05163-.03986-.08992,0,0-.01,0-.01-.01-1.46642-2.7001-3.08021-5.72568-4.5634-8.46529.01999.02001.03998.04996.04998.06997-165.01013-287.23702-415.64692-882.63022-403.53149-1193.46936,4.07951-104.66591,88.1281-188.31126,192.71288-192.04153,92.1143-3.28547,200.69379,10.46969,286.62767,30.61985,93.58312-325.69743,246.26999-636.85321,458.06014-915.09749C797.66386-275.84563-214.1867,319.25478,39.32792,1592.99819c208.75964,1126.24494,1140.55524,2354.14531,1865.30175,3206.20191-1.24817,1.76903,1.23818-1.76903,0,0,177.91975,202.67638,363.17881,406.52227,558.17368,604.08153,267.29912-191.50259,530.82373-396.10806,795.10723-617.7438-259.10108-257.46588-503.73334-520.6384-729.69288-785.15026ZM1420.59738,2433.86513c.84886,1.56913,1.71746,3.17821,2.5963,4.82725-.85885-1.58908-1.72745-3.19816-2.5963-4.82725ZM1457.40366,2502.26682c.92857,1.699,1.83716,3.39806,2.73599,5.07711-.89883-1.64904-1.80742-3.3481-2.73599-5.07711ZM1403.01311,2401.17342c-.01-.01-.01987-.02995-.02986-.03996.01.01.01987.02995.01987.03996.01999.03001.02999.04996.03998.06997-.01-.02001-.01999-.03996-.02999-.06997ZM1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934Z" fill="#ffe480"/>
|
||||
<path d="M2762.56314,2987.43935c-683.41752-683.42045-683.41752-1791.46134,0-2474.87781,683.41752-683.41755,1791.45841-683.41755,2474.87885,0,683.41068,683.41648,683.41068,1791.45737,0,2474.87781l-1237.44089,1237.44089-1237.43796-1237.44089Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 94 94" id="vector" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="group" transform="matrix(1, 0, 0, 1, -6.999915, -7)">
|
||||
<path id="path" d="M 30 81 C 23 80 21.63 71.49 29.16 45.87 C 35 26 46.18 26 58 26 C 73.17 26 81.61 29.18 83.79 35.73 C 84.243 36.883 84.39 38.135 84.214 39.362 C 84.039 40.589 83.548 41.75 82.79 42.73 C 82.613 42.96 82.419 43.178 82.21 43.38 C 85.1 45.31 86.88 48.02 86.52 51.9 C 86.13 56.26 83.16 58.55 78.52 59.66 C 78.849 60.396 79.013 61.194 79 62 C 78.988 63.199 78.695 64.378 78.147 65.444 C 77.598 66.51 76.808 67.433 75.84 68.14 C 70.09 72.76 60 75 46 73 C 43 72.57 41 75 39 77 C 36.62 79.38 34.24 81.61 30 81 Z" fill="#25A5FF"/>
|
||||
<path id="path_1" d="M 58 30 C 66 30 78 31 80 37 C 82.24 43.71 69 43 59 42 C 68 43 83.18 44 82.5 51.5 C 82 57 72 57 55 56 C 63 57 75 58 75 62 C 75 68.28 58.74 71 47.67 69.31 C 39 68 38.06 76.77 31 75 C 27 74 28 63 33 47 C 38.29 30.09 46.49 30 58 30 Z" fill="#ffffff" stroke="#25A5FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,39 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- KAMMERGUT GLOSS TEST (2026-08-12): SVG paint filter lifted verbatim
|
||||
from plan-b's Kammergut wordmark (#paintGloss). Consumed by the
|
||||
.logo-gradient-border::after override in style.css. Revert by
|
||||
deleting this svg block + that css block (one commit). -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<filter id="paintGloss" x="-12%" y="-30%" width="124%" height="160%" color-interpolation-filters="sRGB">
|
||||
<feMorphology in="SourceAlpha" operator="dilate" radius="2.5" result="dilated"/>
|
||||
<feGaussianBlur in="dilated" stdDeviation="6" result="puddle"/>
|
||||
<feFlood flood-color="#fff4dc" flood-opacity="0.85" result="puddleColor"/>
|
||||
<feComposite in="puddleColor" in2="puddle" operator="in" result="puddleHi"/>
|
||||
<feFlood flood-color="#1A1A18" flood-opacity="0.18" result="puddleShadowColor"/>
|
||||
<feComposite in="puddleShadowColor" in2="puddle" operator="in" result="puddleShadow"/>
|
||||
<feOffset in="puddleShadow" dx="0" dy="3" result="puddleShadowOff"/>
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.4" result="bump"/>
|
||||
<feSpecularLighting in="bump" surfaceScale="4" specularConstant="0.9"
|
||||
specularExponent="40" lighting-color="#fff2d4" result="spec2">
|
||||
<fePointLight x="600" y="-200" z="380"/>
|
||||
</feSpecularLighting>
|
||||
<feComposite in="spec2" in2="SourceAlpha" operator="in" result="specClip2"/>
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="dsBlur"/>
|
||||
<feOffset in="dsBlur" dx="0" dy="6" result="dsOff"/>
|
||||
<feComponentTransfer in="dsOff" result="dsFinal">
|
||||
<feFuncA type="linear" slope="0.45"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode in="dsFinal"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
<feMergeNode in="specClip2"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- Splash Screen (only on first visit) -->
|
||||
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||
|
||||
|
||||
@@ -1128,6 +1128,16 @@ html.controller-nav [data-controller-container]:focus {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* KAMMERGUT GLOSS TEST (2026-08-12) — black gloss paint on the badge's
|
||||
inner circle, lifted from plan-b's Kammergut wordmark (.paint-3d
|
||||
gradient + #paintGloss filter, defs injected in App.vue). Revert:
|
||||
delete this block + the svg defs block in App.vue (one commit). */
|
||||
.logo-gradient-border::after {
|
||||
background: linear-gradient(180deg, #2a2a26 0%, #1a1a18 45%, #060604 100%);
|
||||
filter: url(#paintGloss);
|
||||
-webkit-filter: url(#paintGloss);
|
||||
}
|
||||
|
||||
.logo-gradient-border img,
|
||||
.logo-gradient-border svg {
|
||||
border-radius: 9999px;
|
||||
|
||||
Reference in New Issue
Block a user