feat: bell-tower favicon, lfx-clean flows, branded HTML mail
16
PLAN_NEXT.md
|
|
@ -135,6 +135,22 @@ request); anything else → 403 and the frontend forwards to
|
||||||
`/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only,
|
`/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only,
|
||||||
delivery failures never 500 (see §0.4 hardening).
|
delivery failures never 500 (see §0.4 hardening).
|
||||||
|
|
||||||
|
## 0.6 Polish round (bell-tower favicon, lfx-clean flows, HTML mail)
|
||||||
|
|
||||||
|
* Favicon simplified to a stylized bell tower (spire + shaft + bell-arch
|
||||||
|
knockout); PNGs regenerated via `npm run icons`.
|
||||||
|
* `lfx validate --level 4` now green for ALL canonical flows + regression
|
||||||
|
test (`test_lfx_validate.py`). Root cause: required form fields have no
|
||||||
|
edge (values arrive via the form_values envelope). Fix: generator writes
|
||||||
|
a single-space sentinel value for required template fields; the form
|
||||||
|
parser treats whitespace-only values as "no default". L3
|
||||||
|
Message-vs-str warnings are known lfx-validator noise (MessageTextInput
|
||||||
|
accepts Message at runtime).
|
||||||
|
* New-input authoring docs: `backend/components/kirche/README.md`.
|
||||||
|
* Magic-link + access-notification mails are branded multipart
|
||||||
|
(text + HTML): kirchenblau header, serif headline, real button link,
|
||||||
|
German copy, email-safe tables/inline styles, no external assets.
|
||||||
|
|
||||||
## 1. Locked decisions (do not re-ask)
|
## 1. Locked decisions (do not re-ask)
|
||||||
|
|
||||||
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
|
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ needs judgment. Every step is documented and traceable.
|
||||||
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
|
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
|
||||||
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
|
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
|
||||||
the matching form automatically. Flows without such a node fall back to a
|
the matching form automatically. Flows without such a node fall back to a
|
||||||
single textarea.
|
single textarea. See
|
||||||
|
`backend/components/kirche/README.md` for authoring new form fields.
|
||||||
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the
|
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the
|
||||||
logged-in webapp: *Workflows* (choose + run with dynamic form),
|
logged-in webapp: *Workflows* (choose + run with dynamic form),
|
||||||
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,
|
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise."""
|
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.
|
||||||
|
|
||||||
|
Mail design follows the web branding (see frontend/src/theme.ts):
|
||||||
|
kirchenblau header, serif headline, white card on paper background.
|
||||||
|
Email-safe by construction: table layout, inline styles only, system
|
||||||
|
fonts, no external images (many clients block them / track opens).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,11 +16,72 @@ from app.settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Brand palette (mirrors frontend/src/theme.ts kirchenblau).
|
||||||
|
BLUE = "#1d4a79"
|
||||||
|
BLUE_LIGHT = "#eef4fb"
|
||||||
|
INK = "#162f4c"
|
||||||
|
MUTED = "#5b6b7c"
|
||||||
|
|
||||||
|
BLUE = "#1d4a79"
|
||||||
|
BLUE_LIGHT = "#eef4fb"
|
||||||
|
INK = "#162f4c"
|
||||||
|
MUTED = "#5b6b7c"
|
||||||
|
|
||||||
|
|
||||||
def magic_link(token: str) -> str:
|
def magic_link(token: str) -> str:
|
||||||
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
|
||||||
|
"""Branded HTML mail body (table layout, inline styles, no images)."""
|
||||||
|
button = ""
|
||||||
|
if button_url:
|
||||||
|
button = (
|
||||||
|
'<table role="presentation" cellpadding="0" cellspacing="0" '
|
||||||
|
'style="margin:24px auto 8px auto;"><tr>'
|
||||||
|
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
|
||||||
|
f'<a href="{button_url}" '
|
||||||
|
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
|
||||||
|
'Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;'
|
||||||
|
'font-weight:600;color:#ffffff;text-decoration:none;'
|
||||||
|
f'border-radius:8px;">{button_label}</a></td></tr></table>'
|
||||||
|
)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||||
|
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
|
||||||
|
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="{BLUE}" style="padding:20px 28px;">
|
||||||
|
<span style="color:#ffffff;font-size:18px;font-weight:700;
|
||||||
|
letter-spacing:0.02em;">Kirche Einfach</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
|
||||||
|
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
|
||||||
|
color:{INK};">{title}</h1>
|
||||||
|
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
|
||||||
|
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
|
||||||
|
color:{MUTED};">{intro}</p>
|
||||||
|
{button}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||||
|
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
def _send(message: EmailMessage) -> None:
|
def _send(message: EmailMessage) -> None:
|
||||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
|
|
@ -23,6 +90,33 @@ def _send(message: EmailMessage) -> None:
|
||||||
smtp.send_message(message)
|
smtp.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _branded_mail(
|
||||||
|
subject: str,
|
||||||
|
to_email: str,
|
||||||
|
headline: str,
|
||||||
|
intro: str,
|
||||||
|
button_label: str,
|
||||||
|
button_url: str,
|
||||||
|
note: str,
|
||||||
|
) -> EmailMessage:
|
||||||
|
"""Text + branded HTML alternative; the link is a real button."""
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["To"] = to_email
|
||||||
|
message["From"] = settings.mail_from or settings.contact_to or to_email
|
||||||
|
if settings.contact_to:
|
||||||
|
message["Reply-To"] = settings.contact_to
|
||||||
|
message.set_content(
|
||||||
|
f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n"
|
||||||
|
)
|
||||||
|
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
|
||||||
|
# the palette is inlined via the f-string constants above.
|
||||||
|
message.add_alternative(
|
||||||
|
_page(headline, intro, button_label, button_url, note), subtype="html"
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
def send_magic_link(to_email: str, link: str) -> bool:
|
def send_magic_link(to_email: str, link: str) -> bool:
|
||||||
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
|
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
|
||||||
|
|
||||||
|
|
@ -36,14 +130,22 @@ def send_magic_link(to_email: str, link: str) -> bool:
|
||||||
if not settings.smtp_host:
|
if not settings.smtp_host:
|
||||||
logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
|
logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
|
||||||
return False
|
return False
|
||||||
message = EmailMessage()
|
message = _branded_mail(
|
||||||
message["Subject"] = "Your kirche-einfach login link"
|
subject="Dein Login-Link für Kirche Einfach",
|
||||||
message["To"] = to_email
|
to_email=to_email,
|
||||||
message["From"] = settings.mail_from or settings.contact_to or to_email
|
headline="Anmeldung bei Kirche Einfach",
|
||||||
if settings.contact_to:
|
intro=(
|
||||||
message["Reply-To"] = settings.contact_to
|
"Klicke auf den Button, um dich anzumelden. Der Link ist "
|
||||||
message.set_content(
|
"15 Minuten gültig und funktioniert nur einmal."
|
||||||
f"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n"
|
),
|
||||||
|
button_label="Jetzt anmelden",
|
||||||
|
button_url=link,
|
||||||
|
note=(
|
||||||
|
"Falls du den Button nicht nutzen kannst, kopiere den Link "
|
||||||
|
"aus der Textversion dieser E-Mail in deinen Browser. "
|
||||||
|
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
|
||||||
|
"alles gut; sonst kannst du sie ignorieren."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_send(message)
|
_send(message)
|
||||||
|
|
@ -59,12 +161,17 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
||||||
if not settings.smtp_host or not settings.contact_to:
|
if not settings.smtp_host or not settings.contact_to:
|
||||||
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
||||||
return False
|
return False
|
||||||
message = EmailMessage()
|
message = _branded_mail(
|
||||||
message["Subject"] = f"New access request: {name}"
|
subject=f"Neue Zugangsanfrage: {name}",
|
||||||
message["To"] = settings.contact_to
|
to_email=settings.contact_to,
|
||||||
message["From"] = settings.mail_from or settings.contact_to
|
headline="Neue Zugangsanfrage",
|
||||||
message.set_content(
|
intro=(
|
||||||
f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n"
|
f"{name} ({email}) hat Zugang angefordert und wartet auf "
|
||||||
|
"Freigabe in der Verwaltung."
|
||||||
|
),
|
||||||
|
button_label="In der Verwaltung prüfen",
|
||||||
|
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
|
||||||
|
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_send(message)
|
_send(message)
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
||||||
|
|
||||||
value = field.get("value")
|
value = field.get("value")
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
# Whitespace-only string values are the flow generator's sentinel
|
||||||
|
# for "required, filled at runtime" (see make_flow_fixtures.py) —
|
||||||
|
# not a real default for the web form.
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return prop, ui
|
||||||
# A default outside its enum would fail validation on touch.
|
# A default outside its enum would fail validation on touch.
|
||||||
enum = prop.get("enum")
|
enum = prop.get("enum")
|
||||||
if not (enum is not None and value not in enum):
|
if not (enum is not None and value not in enum):
|
||||||
|
|
|
||||||
58
backend/components/kirche/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Authoring web forms (CustomInputForm)
|
||||||
|
|
||||||
|
Flows that should render a web form start with a `CustomInputForm` node
|
||||||
|
(this package — the `kirche` category in the Langflow palette). The API
|
||||||
|
derives a JSON Schema + uiSchema from the node's template
|
||||||
|
(`app/flows/input_schema.py`) and the webapp renders it with RJSF.
|
||||||
|
|
||||||
|
## Adding a new form field
|
||||||
|
|
||||||
|
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
||||||
|
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||||
|
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
||||||
|
one web-form field. Add an `lfx.io` input, e.g.
|
||||||
|
```python
|
||||||
|
from lfx.io import DropdownInput
|
||||||
|
|
||||||
|
DropdownInput(
|
||||||
|
name="room", # JSON-Schema property key
|
||||||
|
display_name="Room", # form label (title)
|
||||||
|
info="Where should it take place", # help text (description)
|
||||||
|
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||||
|
value="Chapel", # prefill (default)
|
||||||
|
required=True, # marks the field required
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
|
||||||
|
`MultilineInput` → textarea, `SecretStrInput` → password,
|
||||||
|
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
|
||||||
|
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||||
|
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||||
|
(real upload later).
|
||||||
|
3. Add the new field to `collect()` so it reaches the JSON output.
|
||||||
|
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
||||||
|
input, `show=False`). Submitted web-form values arrive there as
|
||||||
|
`{"payload": {...}}` and are merged over the defaults. Never make form
|
||||||
|
fields required *with* an incoming edge — that's the envelope's job.
|
||||||
|
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
||||||
|
The API watcher syncs + deploys it automatically; the form schema is
|
||||||
|
regenerated per version.
|
||||||
|
|
||||||
|
## Conventions the pipeline relies on
|
||||||
|
|
||||||
|
- `required=True` on a template field: `lfx validate --level 4` demands a
|
||||||
|
value or an edge, so the canonical-flow generator
|
||||||
|
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||||
|
sentinel value for such fields. The form parser treats whitespace-only
|
||||||
|
values as "no default" — don't ship real whitespace defaults.
|
||||||
|
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
||||||
|
- Unknown future input types degrade to a plain text field in the web
|
||||||
|
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
||||||
|
|
||||||
|
## Regenerating the bundled fixtures
|
||||||
|
|
||||||
|
After editing the component source (`form_inputs.py`), refresh the
|
||||||
|
canonical + test-fixture exports:
|
||||||
|
|
||||||
|
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
||||||
|
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
||||||
|
|
@ -143,6 +143,19 @@ def kirche_node(
|
||||||
node_dict = _normalize(node_dict, node_id, comp_name, *position)
|
node_dict = _normalize(node_dict, node_id, comp_name, *position)
|
||||||
for field, value in (values or {}).items():
|
for field, value in (values or {}).items():
|
||||||
node_dict["data"]["node"]["template"][field]["value"] = value
|
node_dict["data"]["node"]["template"][field]["value"] = value
|
||||||
|
# lfx validate --level 4 errors on required inputs with no value and no
|
||||||
|
# incoming edge. Our form fields are filled at runtime via the
|
||||||
|
# form_values envelope (collect() merges), never via edges — so required
|
||||||
|
# template fields carry a single-space sentinel value. The form parser
|
||||||
|
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
||||||
|
# default", keeping the web form clean.
|
||||||
|
for field_def in node_dict["data"]["node"]["template"].values():
|
||||||
|
if (
|
||||||
|
isinstance(field_def, dict)
|
||||||
|
and field_def.get("required")
|
||||||
|
and field_def.get("value") in (None, "", [], {})
|
||||||
|
):
|
||||||
|
field_def["value"] = " "
|
||||||
return node_dict
|
return node_dict
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,3 +316,34 @@ async def test_magic_request_rate_limit(client, session):
|
||||||
"/api/auth/magic/request", json={"email": MEMBER}
|
"/api/auth/magic/request", json={"email": MEMBER}
|
||||||
)
|
)
|
||||||
assert limited.status_code == 429
|
assert limited.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
||||||
|
from app.auth import mail
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
||||||
|
message = mail._branded_mail(
|
||||||
|
subject="Dein Login-Link",
|
||||||
|
to_email="a@example.com",
|
||||||
|
headline="Anmeldung bei Kirche Einfach",
|
||||||
|
intro="Klicke auf den Button.",
|
||||||
|
button_label="Jetzt anmelden",
|
||||||
|
button_url="http://localhost:3000/login?token=GEHEIM",
|
||||||
|
note="Gültig 15 Minuten.",
|
||||||
|
)
|
||||||
|
html_part = next(
|
||||||
|
p for p in message.walk() if p.get_content_type() == "text/html"
|
||||||
|
)
|
||||||
|
html = html_part.get_content()
|
||||||
|
# Button = anchor with href around the label; token never in text-only form.
|
||||||
|
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
|
||||||
|
assert ">Jetzt anmelden</a>" in html
|
||||||
|
assert mail.BLUE in html # kirchenblau brand bar
|
||||||
|
assert 'lang="de"' in html
|
||||||
|
# Multipart: plain-text fallback keeps the raw link.
|
||||||
|
parts = [p.get_content_type() for p in message.walk()]
|
||||||
|
assert "text/plain" in parts and "text/html" in parts
|
||||||
|
plain = next(
|
||||||
|
p.get_content() for p in message.walk() if p.get_content_type() == "text/plain"
|
||||||
|
)
|
||||||
|
assert "http://localhost:3000/login?token=GEHEIM" in plain
|
||||||
|
|
|
||||||
34
backend/tests/test_lfx_validate.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Canonical flows must pass lfx's strict validator (level 4).
|
||||||
|
|
||||||
|
Guards the source-of-truth directory against authoring mistakes: the
|
||||||
|
input_schema parser and the LFX runtime both assume well-formed exports.
|
||||||
|
Runs `lfx validate --level 4` (the dev dep, same minor as Langflow).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_flows_pass_lfx_validate():
|
||||||
|
files = sorted(FLOW_DIR.glob("*.json"))
|
||||||
|
assert files, "no canonical flows found"
|
||||||
|
process = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"lfx",
|
||||||
|
"validate",
|
||||||
|
"--level",
|
||||||
|
"4",
|
||||||
|
*[str(f) for f in files],
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
assert process.returncode == 0, (
|
||||||
|
f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
|
||||||
|
)
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 661 B After Width: | Height: | Size: 679 B |
|
|
@ -7,16 +7,9 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
|
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
|
||||||
<!-- tower -->
|
<!-- stylized bell tower: spire + shaft + bell arch -->
|
||||||
<rect class="fg" x="26" y="22" width="12" height="28"/>
|
<path class="fg" d="M32 9 L41 25 H23 Z"/>
|
||||||
<!-- spire -->
|
<rect class="fg" x="25" y="25" width="14" height="27"/>
|
||||||
<path class="fg" d="M32 8 L40 22 H24 Z"/>
|
<!-- bell arch knockout -->
|
||||||
<!-- cross -->
|
<path class="bg" d="M28.5 40 v-4 a3.5 3.5 0 0 1 7 0 v4 Z"/>
|
||||||
<rect class="fg" x="31.2" y="2.5" width="1.6" height="6"/>
|
|
||||||
<rect class="fg" x="29.4" y="4" width="5.2" height="1.6"/>
|
|
||||||
<!-- nave -->
|
|
||||||
<rect class="fg" x="14" y="36" width="12" height="14"/>
|
|
||||||
<path class="fg" d="M12 36 L20 28 L28 36 Z"/>
|
|
||||||
<!-- door knockout -->
|
|
||||||
<path class="bg" d="M29 50 v-5 a3 3 0 0 1 6 0 v5 Z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9 KiB After Width: | Height: | Size: 9.3 KiB |