From 9e21285193a712e61a2bd9bdcb27c24092407c4b Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Tue, 30 Jun 2026 23:00:53 -0700 Subject: [PATCH 1/3] :art: --- src/pages/tools/3d-to-svg.astro | 109 +++++++++++++---------- src/pages/tools/_3d-to-svg/studio.css.ts | 71 ++++++++------- src/pages/tools/_3d-to-svg/studio.ts | 2 - 3 files changed, 102 insertions(+), 80 deletions(-) diff --git a/src/pages/tools/3d-to-svg.astro b/src/pages/tools/3d-to-svg.astro index 3424193..8b8c0e1 100644 --- a/src/pages/tools/3d-to-svg.astro +++ b/src/pages/tools/3d-to-svg.astro @@ -8,7 +8,6 @@ import { areaViewport, attribution, button, - buttonRow, canvas, checkboxRow, colorInput, @@ -18,25 +17,27 @@ import { dropZone, fieldRow, fieldset, + headButton, ids, intro, label, legend, - loadButton, loadingOverlay, modeField, modeGroup, modeOption, number, + paneBlock, poseGrid, preview, spinner, scanFrame, + sectionHead, + sectionHeading, slider, sliderRow, + snapRow, snippetBlock, - snippetHead, - snippetHeading, snippetPre, status, viewport @@ -83,40 +84,61 @@ const jsonLd = {
-
- - - -
- Drop a 3D model - .glb, .obj, or .stl - +
+
+

Model

+
-
-
+
+ + +
+ Drop a 3D model + .glb, .obj, or .stl + +
+
+
+
-
+
+
+

SVG

+ +
+
+
Pose @@ -137,12 +159,11 @@ const jsonLd = {
- - -
+
+
@@ -168,18 +189,14 @@ const jsonLd = {
- -
- -

-
-

Exported SVG

+
+

Exported SVG

- + Drop a 3D model .glb, .obj, or .stl +

+

@@ -137,7 +140,7 @@ const jsonLd = { Download
-
+
@@ -146,17 +149,17 @@ const jsonLd = {
- +
- +
- +
@@ -192,8 +195,6 @@ const jsonLd = {
-

-

Exported SVG

diff --git a/src/pages/tools/_3d-to-svg/studio.css.ts b/src/pages/tools/_3d-to-svg/studio.css.ts index eac3f6a..7f9dbb2 100644 --- a/src/pages/tools/_3d-to-svg/studio.css.ts +++ b/src/pages/tools/_3d-to-svg/studio.css.ts @@ -13,8 +13,8 @@ export const ids = { viewport: 'studio-viewport', canvas: 'studio-canvas', dropZone: 'studio-drop-zone', + dropError: 'studio-drop-error', fileInput: 'studio-file-input', - status: 'studio-status', preview: 'studio-preview', x: 'studio-x', y: 'studio-y', @@ -31,7 +31,8 @@ export const ids = { snippetInline: 'studio-snippet-inline', copyInline: 'studio-copy-inline', loading: 'studio-loading', - loadButton: 'studio-load' + loadButton: 'studio-load', + live: 'studio-live' } as const; /* @@ -193,28 +194,38 @@ export const spinner = style({ } }); -export const status = style({ +// Error line shown inside the drop zone when a model fails to load, so the reason +// appears in context beside the prompt. Empty by default, so it costs no layout. +export const dropError = style({ + margin: 0, fontSize: '0.85rem', - color: palette.textMuted, + color: palette.linkHover, '@media': { [media.dark]: { - color: palette.textMutedDark + color: palette.linkHoverDark } }, selectors: { - // Only take up space (and reserve a line) once it actually holds a message, - // so an empty status doesn't leave an uneven gap above the exported SVG. - // Stays in flow rather than display:none so the live region still announces. - '&:not(:empty)': { - marginTop: '0.75rem', - minHeight: '1.4em' - }, - '&[data-error="true"]': { - color: palette.linkHover + '&:empty': { + display: 'none' } } }); +// Screen-reader-only live region for transient announcements (e.g. loading), so +// they're conveyed without the visible layout shift a real status line causes. +export const visuallyHidden = style({ + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0 +}); + // Inline preview host. `color` drives the SVG's currentColor fill at runtime. export const preview = style([ pane, diff --git a/src/pages/tools/_3d-to-svg/studio.ts b/src/pages/tools/_3d-to-svg/studio.ts index 5f6803f..50de136 100644 --- a/src/pages/tools/_3d-to-svg/studio.ts +++ b/src/pages/tools/_3d-to-svg/studio.ts @@ -69,7 +69,8 @@ function initStudio(): void { const fileInputEl = requireElement(ids.fileInput); const loadingEl = requireElement(ids.loading); const loadButtonEl = requireElement(ids.loadButton); - const statusEl = requireElement(ids.status); + const dropErrorEl = requireElement(ids.dropError); + const liveEl = requireElement(ids.live); const previewEl = requireElement(ids.preview); const xRange = requireElement(ids.x); @@ -184,16 +185,25 @@ function initStudio(): void { previewEl.innerHTML = `
`; snippetInline.textContent = ''; snippetPre.dataset.loading = 'true'; - setStatus('', false); + // Fresh load: drop any prior error, and announce loading to screen readers + // only (sighted users have the spinner) via the visually-hidden live region. + dropErrorEl.textContent = ''; + liveEl.textContent = 'Loading model…'; } function hideLoading(): void { loadingEl.dataset.hidden = 'true'; } - /** On failure, surface the message and reveal the drop prompt so the user can retry. */ + /** + * On failure, show the reason inside the drop prompt (revealed for retry) and + * announce it through the live region. The loading announcement is replaced, so + * hideLoading leaves it intact. + */ function onLoadError(error: unknown): void { - setStatus(messageFrom(error), true); + const message = messageFrom(error); + dropErrorEl.textContent = message; + liveEl.textContent = message; dropZoneEl.dataset.hidden = 'false'; } @@ -209,7 +219,8 @@ function initStudio(): void { mountViewport(threeModule, geometryModule.CAMPOS); rebuildDisplay(threeModule); dropZoneEl.dataset.hidden = 'true'; - setStatus('', false); + // Clear the loading announcement now that the model is mounted. + liveEl.textContent = ''; // Every freshly loaded model starts at the default pose, then drifts. orientation = orientationFromMat3(threeModule, geometryModule.LOCKED_ORIENTATION); syncControls(); @@ -356,7 +367,12 @@ function initStudio(): void { isWorkerBusy = false; const reply = event.data; if (reply.type === 'error') { - setStatus(reply.message, true); + // SVG generation failed with a model still loaded, so show the reason where + // the icon would render (preview is aria-hidden; the live region carries it + // to screen readers) and stop the snippet spinner. + previewEl.textContent = reply.message; + snippetPre.dataset.loading = 'false'; + liveEl.textContent = reply.message; } else if (reply.id === latestRequestId) { applySvg(reply.svg); } @@ -705,11 +721,6 @@ function initStudio(): void { anchor.click(); URL.revokeObjectURL(url); } - - function setStatus(message: string, isError: boolean): void { - statusEl.textContent = message; - statusEl.dataset.error = String(isError); - } } /* From 83539b051122691bbd40b135ff48ccfffed20430 Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Tue, 30 Jun 2026 23:46:12 -0700 Subject: [PATCH 3/3] self-nits --- src/pages/tools/3d-to-svg.astro | 15 ++++-- src/pages/tools/_3d-to-svg/studio.css.ts | 60 ++++++++++++------------ src/pages/tools/_3d-to-svg/studio.ts | 17 ++++--- 3 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/pages/tools/3d-to-svg.astro b/src/pages/tools/3d-to-svg.astro index 31ef130..9a45d53 100644 --- a/src/pages/tools/3d-to-svg.astro +++ b/src/pages/tools/3d-to-svg.astro @@ -39,9 +39,11 @@ import { sliderRow, snapRow, snippetBlock, + snippetCode, + snippetFrame, snippetPre, - viewport, - visuallyHidden + snippetSpinnerHost, + viewport } from './_3d-to-svg/studio.css'; const pageTitle = '3D to SVG: turn a 3D model into an SVG icon | Greg Linscheid'; @@ -123,7 +125,7 @@ const jsonLd = {
-

+

@@ -210,7 +212,12 @@ const jsonLd = {
-
+
+
+
+
+
+

diff --git a/src/pages/tools/_3d-to-svg/studio.css.ts b/src/pages/tools/_3d-to-svg/studio.css.ts index 7f9dbb2..ef2c4f6 100644 --- a/src/pages/tools/_3d-to-svg/studio.css.ts +++ b/src/pages/tools/_3d-to-svg/studio.css.ts @@ -27,8 +27,10 @@ export const ids = { color: 'studio-color', mode: 'studio-mode', download: 'studio-download', + snippetFrame: 'studio-snippet-frame', snippet: 'studio-snippet', snippetInline: 'studio-snippet-inline', + snippetSpinner: 'studio-snippet-spinner', copyInline: 'studio-copy-inline', loading: 'studio-loading', loadButton: 'studio-load', @@ -212,20 +214,6 @@ export const dropError = style({ } }); -// Screen-reader-only live region for transient announcements (e.g. loading), so -// they're conveyed without the visible layout shift a real status line causes. -export const visuallyHidden = style({ - position: 'absolute', - width: '1px', - height: '1px', - padding: 0, - margin: '-1px', - overflow: 'hidden', - clip: 'rect(0, 0, 0, 0)', - whiteSpace: 'nowrap', - border: 0 -}); - // Inline preview host. `color` drives the SVG's currentColor fill at runtime. export const preview = style([ pane, @@ -416,12 +404,32 @@ export const sectionHeading = style({ fontWeight: 600 }); +// Hosts the exported SVG snippet; the spinner overlays this box while loading. +export const snippetFrame = style({ + position: 'relative' +}); + +export const snippetSpinnerHost = style({ + position: 'absolute', + inset: 0, + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + selectors: { + '&[hidden]': { + display: 'none' + } + } +}); + // Mirrors shiki's github-light/github-dark themes so this matches the block. export const snippetPre = style({ margin: 0, padding: '1.1em 1.25em', maxWidth: '100%', - maxHeight: '15rem', + height: '15rem', overflowY: 'auto', // The exported SVG is one long line. We wrap it instead of blowing out the width. whiteSpace: 'pre-wrap', @@ -439,23 +447,13 @@ export const snippetPre = style({ backgroundColor: '#24292e', borderColor: palette.borderDark } - }, + } +}); + +export const snippetCode = style({ selectors: { - // While a model loads, centre a spinner where the SVG markup will land. - '&[data-loading="true"]': { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - minHeight: '6rem' - }, - '&[data-loading="true"]::after': { - content: '""', - width: '2.25rem', - height: '2.25rem', - borderRadius: '50%', - border: `3px solid ${palette.border}`, - borderTopColor: palette.accent, - animation: `${spin} 0.8s linear infinite` + [`${snippetFrame}[data-loading="true"] &`]: { + visibility: 'hidden' } } }); diff --git a/src/pages/tools/_3d-to-svg/studio.ts b/src/pages/tools/_3d-to-svg/studio.ts index 50de136..db9d5bb 100644 --- a/src/pages/tools/_3d-to-svg/studio.ts +++ b/src/pages/tools/_3d-to-svg/studio.ts @@ -87,7 +87,8 @@ function initStudio(): void { const modeGroup = requireElement(ids.mode); const downloadButton = requireElement(ids.download); - const snippetPre = requireElement(ids.snippet); + const snippetFrameEl = requireElement(ids.snippetFrame); + const snippetSpinnerEl = requireElement(ids.snippetSpinner); const snippetInline = requireElement(ids.snippetInline); const copyInlineButton = requireElement(ids.copyInline); @@ -183,8 +184,8 @@ function initStudio(): void { loadingEl.dataset.hidden = 'false'; currentSvg = ''; previewEl.innerHTML = `

`; - snippetInline.textContent = ''; - snippetPre.dataset.loading = 'true'; + snippetFrameEl.dataset.loading = 'true'; + snippetSpinnerEl.hidden = false; // Fresh load: drop any prior error, and announce loading to screen readers // only (sighted users have the spinner) via the visually-hidden live region. dropErrorEl.textContent = ''; @@ -198,7 +199,7 @@ function initStudio(): void { /** * On failure, show the reason inside the drop prompt (revealed for retry) and * announce it through the live region. The loading announcement is replaced, so - * hideLoading leaves it intact. + * `hideLoading` leaves it intact. */ function onLoadError(error: unknown): void { const message = messageFrom(error); @@ -368,10 +369,11 @@ function initStudio(): void { const reply = event.data; if (reply.type === 'error') { // SVG generation failed with a model still loaded, so show the reason where - // the icon would render (preview is aria-hidden; the live region carries it + // the icon would render (preview is aria-hidden. The live region carries it // to screen readers) and stop the snippet spinner. previewEl.textContent = reply.message; - snippetPre.dataset.loading = 'false'; + snippetFrameEl.dataset.loading = 'false'; + snippetSpinnerEl.hidden = true; liveEl.textContent = reply.message; } else if (reply.id === latestRequestId) { applySvg(reply.svg); @@ -493,8 +495,9 @@ function initStudio(): void { } function updateSnippets(): void { - snippetPre.dataset.loading = 'false'; snippetInline.innerHTML = highlightSvg(currentSvg.trim()); + snippetFrameEl.dataset.loading = 'false'; + snippetSpinnerEl.hidden = true; } /*