Refactor: Replace img-based De Gruyter icons with Svelte icon components #191
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
All De Gruyter SVG icons are rendered via
<img>tags pointing to/static/degruyter-icons/. Because<img>elements don't inherit CSScolor, icons cannot respond to hover/active/focus state changes. This causes:filter: invert(1)CSS hack inlayout.cssinstead of proper color inheritanceProposed solution: Svelte icon components
Convert all 358 SVG files (not just the 22 currently used) into Svelte components with
fill="currentColor". Delete the original SVG files afterwards - the Svelte components ARE the icons, no duplication.Why not a Vite plugin? Plugins like
@poppanator/sveltekit-svggenerate legacy-mode Svelte components, which adds fragility with Svelte 5 runes. Script-generated components are simpler, fully typed, and zero-dependency.Why not just fix the SVG source files? Changing
fill="#000000"tofill="currentColor"in the SVGs doesn't help because<img>tags don't inherit CSScolorregardless. We still need a different rendering method.Icon set inventory
Architecture
Every icon component has the same API: a
sizeprop ('xs' | 'sm' | 'md' | 'lg'), aclassprop, and typed SVG attributes spread onto the<svg>.If the requested size has dedicated path data in the original icon set, use it. If not, render from the nearest available size and scale via CSS class (
h-3 w-3/h-4 w-4/h-6 w-6/h-8 w-8) - the viewBox ensures crisp scaling.Shared types (
types.ts)This extends
SVGAttributes<SVGSVGElement>fromsvelte/elements, giving consumers full autocomplete for standard SVG attributes (opacity,stroke-width,role,style, etc.).Example component
Conversion approach
Write a Node.js conversion script (
scripts/convert-icons.js) that:static/degruyter-icons/recursively.sveltefiles with the uniformIconPropsinterfacetypes.tsandindex.tsbarrel fileThis avoids hand-converting 358 files and ensures accuracy.
Components currently using icons (20 files)
With broken hover/active states (6):
Static/decorative icons (14):
DocumentViewer, PersonChipRow, DropZone, ConversationTimeline, PersonCard, PersonDocumentList, PersonsEmptyState, FileSectionEdit, persons/+page, documents/[id]/edit, enrich/+page, enrich/[id]/+page, enrich/done/+page, routes/SearchFilterBar
Implementation plan
Phase 1: Build conversion script
scripts/convert-icons.js<path>and<polygon>elements (both exist in the set)fill="#000000"withfill="currentColor"IconPropsinterface +types.ts+index.tsPhase 2: Replace img tags across 20 component files
<img src="/degruyter-icons/...">with the corresponding<IconName size="md" class="..." />importaria-hidden="true"from usage sites (already on the svg in the component)Phase 3: Remove dark mode CSS hack
filter: invert(1)rules fromlayout.cssbrightness-0 invertutility classes from UserMenu.sveltePhase 4: Delete original SVG files + script
static/degruyter-icons/directory entirelyscripts/convert-icons.js(one-time use)Testing
aria-hidden="true"still present on decorative icons👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Test strategy is underspecified. The issue lists "visual check" and "build: no svelte-check regressions" but doesn't define unit tests for the icon components themselves. Each generated component is code — it should have at least a smoke test proving it renders an
<svg>withfill="currentColor"and responds to thesizeprop. A single parameterized test file iterating over all exports fromindex.tswould cover this cheaply.The
variantsobject usesObject.keys()[0]for fallback. Object key order in JS is insertion-order for string keys, but this is an implicit contract. If the conversion script emits sizes in an unexpected order, the fallback silently picks the wrong variant. Consider an explicitfallbackSizeconstant or an ordered array of preferred fallbacks (['md', 'sm', 'lg', 'xs']).The example component has
Partial<Record<IconSize, ...>>but indexes it withsizedirectly. TypeScript won't narrow this —variants[size]could beundefined. The??fallback handles runtime, but the types are loose. A stricter approach: define the available sizes as a union literal per component (generated by the script), sosizeis constrained to what actually exists.Barrel file with ~152 re-exports. This is fine for tree-shaking (Vite handles it), but confirm that
svelte-checkand the dev server don't slow down noticeably. If they do, direct imports (import IconClose from '$lib/icons/IconClose.svelte') without the barrel is the fallback.aria-hidden="true"hardcoded in every component. This is correct for decorative icons inside labeled buttons, but if an icon is ever used standalone as the only content (e.g., an icon-only button without a visible label),aria-hiddenshould be overridable. Since{...rest}is spread after, a consumer can override it — worth noting in the implementation.Suggestions
icons.spec.tsthat imports every icon from the barrel and renders each at every size. One test file, parameterized, catches regressions for all 152 components..sveltefile back and verify it contains a valid<svg>with at least one<path>or<polygon>./dev/iconsor a Storybook-like static page) that renders all icons at all sizes — makes review during Phase 1 much faster than spot-checking individual components.🏗️ Markus Keller — Application Architect
Questions & Observations
152 components is a lot of generated code to own. The conversion script runs once and gets deleted, meaning future maintenance (adding a new icon, fixing a path) is manual editing of generated Svelte files. If De Gruyter ships an updated icon set, you'd need to re-create the script or manually diff 152 files. Consider keeping the script in the repo (perhaps in
scripts/with a note that it's re-runnable) so the conversion is repeatable. One-time scripts have a habit of being needed twice.The
$lib/icons/directory becomes a de facto icon library. 152 components is a module boundary worth thinking about. Will these ever be shared across projects? If not,$lib/icons/is fine. If yes, consider whether this should eventually be an internal package. For now, the flat directory is the right call — just flag it as a potential extraction boundary.sizeClasseshardcodes Tailwind values inside each component. This couples every icon to Tailwind's utility class names. If the project ever changes its sizing scale (unlikely but possible), you'd update 152 files. An alternative: movesizeClassesintotypes.tsas a shared constant. One definition, 152 consumers.Fallback behavior when a size variant doesn't exist. The issue says "scale from the nearest available size via CSS class." This is a sensible default, but the consumer gets no signal that they're using a scaled fallback vs. a native variant. Is that acceptable? For this project, yes — the visual difference between a scaled 24px icon at 16px and a native 16px icon is minor. Just be aware of it.
Phase 4 deletes the entire
static/degruyter-icons/directory. This is a one-way door. Before deleting, verify no other consumer references these files — checklayout.css(thefilter: invertrule usesimg[src*='degruyter-icons']), any HTML email templates, or external tools that might hotlink to static assets.Suggestions
scripts/convert-icons.jsin the repo, gitignored or in ascripts/directory, so the conversion is repeatable if the icon set is updated.sizeClassesinto the sharedtypes.tsmodule to avoid 152 copies of the same mapping.🧪 Sara Holt — QA Engineer
Questions & Observations
No acceptance criteria for visual correctness. "Visual check: all 22 icons render correctly" is not testable in CI. How do we know an icon converted correctly? The conversion script could silently produce a broken path (e.g., polygon points not converted to a
dattribute properly). We need an automated verification step.Polygon-to-path conversion is a risk area. The issue notes both
<path>and<polygon>elements exist in the SVG set. The example component only shows<path d={...}>. How will<polygon points="...">be handled? If converted to<path>, the conversion logic needs testing. If rendered as<polygon>, the component template needs to support both element types. Either way, this needs explicit test coverage.Dark mode regression risk is high. Phase 3 removes the global
filter: invert(1)CSS hack. If even one icon is missed during Phase 2 replacement (still using<img>) and the CSS hack is already removed, that icon becomes invisible in dark mode. Suggestion: Phase 2 and Phase 3 should be verified together — don't remove the CSS hack until a grep confirms zero remaining<img src="...degruyter-icons..."references.The 6 hover-state components need explicit before/after verification. Each of the 6 components listed should have a test (at minimum a Playwright visual snapshot) confirming the icon color changes on hover. This is the original bug — it should be the primary acceptance criterion.
Suggestions
dattribute catches most conversion bugs.grep -r "degruyter-icons" frontend/src/should return zero matches after Phase 2. This prevents partial migration.<svg>element exists, hasfill="currentColor", has at least one child element. Fast, catches regressions on all 152 components.🔒 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
{@html}is not used — good. The proposed approach generates static Svelte markup with hardcoded path data, not runtime-injected SVG strings. No XSS surface from the icon system itself. This is the correct approach.{...rest}spread on<svg>is safe but worth noting. Spreading arbitrary props onto an SVG element is low-risk (SVG attributes can't execute scripts the wayinnerHTMLcan), but a consumer could theoretically passonloador event handler props. In Svelte 5, event handlers via props are standard, so this is expected behavior — not a vulnerability, just something to be aware of.The conversion script parses untrusted SVG files. The De Gruyter icon set is a trusted first-party source, but the script still parses XML. If using a DOM parser (e.g.,
jsdom,cheerio), ensure it doesn't resolve external entities (XXE). Node.jsDOMParserandcheerioare safe by default, but if usinglibxmljsor similar, disable entity resolution explicitly. Since this is a one-time local script and not a production endpoint, the risk is informational-level.Deleting static assets changes the attack surface slightly. Currently,
static/degruyter-icons/serves ~358 SVG files to any client. These are inert, but they increase the static file footprint. Removing them is a minor positive from a surface reduction perspective.Suggestions
🎨 Leonie Voss — UI/UX Design Lead
Questions & Observations
This directly fixes the reported bug and five other hover-state issues. The edit icon on the document detail page not responding to hover is a real usability problem — users get no visual feedback that the button is interactive.
fill="currentColor"is the correct fix. Full support for this approach.The
sizeClassesmapping needs scrutiny. The proposed mapping isxs: h-3 w-3(12px),sm: h-4 w-4(16px),md: h-6 w-6(24px),lg: h-8 w-8(32px). But the current codebase usesh-5 w-5(20px) for most icons — check the DocumentTopBar, SearchFilterBar, and other components. Ifmdmaps toh-6 w-6(24px) but every usage site currently usesh-5 w-5, consumers will need to override withclass="h-5 w-5"every time, defeating the purpose of the size prop. Consider whether the size prop should map to the actual icon viewBox sizes or to common usage sizes.Touch targets are not affected. The icons themselves are decorative elements inside larger interactive containers (buttons, links). The refactor changes rendering method, not layout. Touch targets remain determined by the parent element's padding. No regression expected here.
Dark mode improvement is significant. The current
filter: invert(1)hack inverts the entire SVG bitmap, which can produce artifacts on icons with subtle opacity or anti-aliasing.currentColorinheritance via CSS custom properties (which this project already uses for dark mode) will produce cleaner, crisper icons in dark mode. This is an underappreciated benefit of the refactor.aria-hidden="true"default is correct for this project's usage. Every icon currently sits inside a button or link with a visible text label or anaria-label. The icon is decorative. However, the{...rest}spread allows overriding toaria-hidden="false"plus adding arole="img"andaria-labelfor any future standalone icon usage — good escape hatch.Suggestions
sizeClassesmapping against actual current usage. Ifh-5 w-5is the dominant size, consider adding it as a named size (e.g.,'default'or adjustingmdto 20px).currentColor(e.g., if any icon relied on the inversion producing a specific shade rather than pure white/black).🔧 Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
Build size impact. 152 Svelte components replacing 358 static SVG files. The static files are served as-is (no processing), while Svelte components get compiled into JS. For the 22 currently used icons, the compiled output should be smaller than loading external SVGs via
<img>(no HTTP requests). The 130 unused icons will be tree-shaken by Vite — they won't appear in the production bundle. Net effect: likely a smaller production build. Worth verifying withnpm run buildbefore/after comparison.CI pipeline impact is minimal. The conversion script runs once locally, not in CI. The generated
.sveltefiles are committed like any other source code. No new CI step needed.svelte-checkand Vitest will cover the generated components as part of the existing pipeline.No infrastructure or deployment changes. This is a pure frontend refactor — no new services, no config changes, no environment variables. The
static/degruyter-icons/deletion reduces the Docker image size slightly (358 SVGs removed from the static asset layer).Cache invalidation is handled by SvelteKit. The old
<img>tags referenced static paths that could be browser-cached indefinitely. After the migration, icons are inlined in the compiled JS bundle, which SvelteKit fingerprints with content hashes. No stale-cache risk for users after deployment.Suggestions
docker imagesoutput before and after.