feat: normalize image paths for main image (#589)

This commit is contained in:
Kalidou Diagne
2025-12-10 01:29:20 +01:00
committed by GitHub
parent 26f2f734dc
commit bb1969f2a6
2 changed files with 84 additions and 130 deletions

View File

@@ -8,7 +8,7 @@ tags:
- privacy
- user experience
- privacy experience
cover: "/privacy-experience-report.webp" # only if post uses a cover image
image: "/articles/privacy-experience-report/privacy-experience-report.webp"
---
## Purpose of the Research
@@ -34,7 +34,7 @@ Through qualitative interviews, we identified recurring themes across all tools
## **Overall Conclusion**
Users dont reject privacy, they reject *invisible, unverified, or cognitively heavy privacy*. To expand adoption, privacy tools must evolve from “power-user cryptography” to *trustable, testable, and human-centered infrastructure.*
Users dont reject privacy, they reject _invisible, unverified, or cognitively heavy privacy_. To expand adoption, privacy tools must evolve from “power-user cryptography” to _trustable, testable, and human-centered infrastructure._
## **Acknowledgements**
@@ -48,33 +48,31 @@ These projects represent the forefront of privacy innovation in the Ethereum eco
We conducted **five one-on-one qualitative interviews** to explore how users interact with different privacy-focused on-chain tools and to understand their perceptions, challenges, and motivations around using them.
Each participant was assigned **one privacy product**—either MEV Protection (Flashbots), Shielded Voting DAO, Privacy Pool, Fluidkey, or Railgun, along with a **specific usage task** (e.g., *create an account and deposit funds*). Participants were asked to **think aloud** as they completed the task, while the interviewer observed and probed to clarify their reasoning, expectations, and emotional responses.
Each participant was assigned **one privacy product**—either MEV Protection (Flashbots), Shielded Voting DAO, Privacy Pool, Fluidkey, or Railgun, along with a **specific usage task** (e.g., _create an account and deposit funds_). Participants were asked to **think aloud** as they completed the task, while the interviewer observed and probed to clarify their reasoning, expectations, and emotional responses.
Following the task, participants reflected on their **overall experience**, discussing what felt intuitive or confusing, what built or reduced trust, and whether they would consider using the tool again—and under what circumstances.
## Thematic affinity mapping
1. *Grouping similar sentiments like “Confusing trust boundary” or “Fear of revealing info unknowingly”*
2. *Color coding for which product each feedback came from — that way you can see patterns across categories*
1. _Grouping similar sentiments like “Confusing trust boundary” or “Fear of revealing info unknowingly”_
2. _Color coding for which product each feedback came from — that way you can see patterns across categories_
### **Pattern 1: Confusion Around Whats Actually Private**
*Behavior: Users frequently misunderstood what data or actions were protected versus exposed.*
_Behavior: Users frequently misunderstood what data or actions were protected versus exposed._
- Many assumed “shielded” meant *full anonymity*, only to discover that votes or transactions were private *temporarily* or *partially*.
- Many assumed “shielded” meant _full anonymity_, only to discover that votes or transactions were private _temporarily_ or _partially_.
- Participants were unsure when privacy applied. E.g., whether frontends, relayers, or RPCs could still leak information.
**Quotes & Evidence:**
> “I thought shielded would mean my vote would always be private… weird that I had to hover to see details.”
>
![Snapshot UI](public/articles/privacy-experience-report/snapshot-UI1.webp)
Snapshot UI
> “There are so many leaks if Im using Alchemy… what is the point?”
>
![Privacy Pool Github](public/articles/privacy-experience-report/Privacy-Pool-Github_1.webp)
@@ -82,11 +80,11 @@ Privacy Pool Github
**Design implication:**
→ Tools need **explicit, contextual privacy indicators** (e.g., *“Your address is hidden until reveal phase”*) and **plain-language explanations** of privacy boundaries.
→ Tools need **explicit, contextual privacy indicators** (e.g., _“Your address is hidden until reveal phase”_) and **plain-language explanations** of privacy boundaries.
### **Pattern 2: Lack of Trust Transparency**
*Behavior: Trust decisions were driven by brand reputation, not by verifiable or visible assurances.*
_Behavior: Trust decisions were driven by brand reputation, not by verifiable or visible assurances._
- Users “trusted” Flashbots or Railgun because theyd heard of them, not because the interface provided proof.
- Even technically advanced users questioned how much custody or data the service retained.
@@ -94,20 +92,16 @@ Privacy Pool Github
**Quotes:**
> “I trusted Shutter because the personal risk is low and Ive heard of them, not because the UI proved anything.”
>
> “Ive heard of Railgun before, so Id trust it a little bit more”
>
> “If the last release was three months ago and not many stars, I dont feel confident.”
>
![Railgun Github](public/articles/privacy-experience-report/Railgun-Github_1.webp)
Railgun Github
> “Only you and Fluidkey can see all your transactions… Fluidkey team? Operator? What does that mean?”
>
![Fluidkey UI](public/articles/privacy-experience-report/Fluidkey-UI_1.webp)
@@ -119,7 +113,7 @@ Fluidkey UI
### **Pattern 3: Overly Technical Setup and Cognitive Overload**
*Behavior: Participants found setup flows fragmented, verbose, or opaque, especially when required to buy ENS, deploy tokens, or manage RPCs.*
_Behavior: Participants found setup flows fragmented, verbose, or opaque, especially when required to buy ENS, deploy tokens, or manage RPCs._
- Even power users noted “a ton of clicks and signatures” with little feedback on what each did.
- Non-technical users struggled to understand why new wallets, seeds, or denominations were needed.
@@ -127,10 +121,8 @@ Fluidkey UI
**Quotes:**
> “There were a ton of clicks and signatures, I didnt even know what I was agreeing to.”
>
> “Why do I need to buy an ENS just to test?”
>
![Snapshot UI](public/articles/privacy-experience-report/Snapshot UI_2.webp)
@@ -141,7 +133,6 @@ Snapshot UI
Snapshot UI
> “I would never trust online generated seed, thats the basic of crypto security.”
>
![Privacy Pool UI](public/articles/privacy-experience-report/Privacy Pool UI.webp)
@@ -153,7 +144,7 @@ Privacy Pool UI
### **Pattern 4: Usability Frictions: Defaults, Navigation, and Feedback**
*Behavior: Users struggled with hidden controls, unclear defaults, and missing confirmations.*
_Behavior: Users struggled with hidden controls, unclear defaults, and missing confirmations._
- Privacy options were buried (“tiny text in the Voting tab”).
- Defaults often undermined privacy (“Any” = public).
@@ -162,17 +153,14 @@ Privacy Pool UI
**Quotes:**
> “Defaults matter, it should default to shielded.”
>
> “Where are the privacy controls? Its just this tiny text.”
>
![Snapshot/Shutter UI](public/articles/privacy-experience-report/Snapshot_Shutter UI_1.webp)
Snapshot/Shutter UI
> “If its private by default, thats perfect. I shouldnt have to think about it.”
>
![Flashbot UI](public/articles/privacy-experience-report/Flashbot UI_1.webp)
@@ -184,7 +172,7 @@ Flashbot UI
### **Pattern 5: Verification Anxiety and Fear of Loss**
*Behavior: Users feared doing irreversible or unverified actions (e.g., sending funds or proofs without visible confirmation).*
_Behavior: Users feared doing irreversible or unverified actions (e.g., sending funds or proofs without visible confirmation)._
- Several wanted test modes or dry runs before risking real funds.
- Even confident users double-checked contract addresses or waited to see funds reappear.
@@ -192,14 +180,12 @@ Flashbot UI
**Quotes:**
> “Theres no testing mode. I wouldnt send 1 ETH through something untested.”
>
![Flashbot UI](public/articles/privacy-experience-report/Flashbot UI_2.webp)
Flashbot UI
> “I want to see the contract before confirming the transaction.”
>
![Etherscan of Privacy Pool tx](public/articles/privacy-experience-report/Etherscan of Privacy Pool tx.webp)
@@ -210,7 +196,6 @@ Etherscan of Privacy Pool tx
Privacy Pool contract on Etherscan
> “I wouldnt download something random, even on this machine.”
>
![Railgun UI](public/articles/privacy-experience-report/Railgun UI_1.webp)
@@ -222,7 +207,7 @@ Railgun UI
### **Pattern 6: Context-Specific Privacy Motivation**
*Behavior: Motivation to use privacy tools varied by context.*
_Behavior: Motivation to use privacy tools varied by context._
- Some wanted privacy for governance (voting), others only for large transfers or identity separation.
- “Compliant privacy” was seen by technical users as “not real privacy.”
@@ -230,10 +215,8 @@ Railgun UI
**Quotes:**
> “Compliant privacy is like giving up, its not really privacy at all.”
>
> “For large fund transfers Id plan ahead, so waiting isnt a big issue.”
>
![Privacy Pool UI](public/articles/privacy-experience-report/Privacy Pool UI_2.webp)
@@ -245,7 +228,7 @@ Privacy Pool UI
### **Pattern 7: Educational Gaps and Mental Model Mismatches**
*Behavior: Even advanced users struggled to articulate how features like stealth addresses, shielded voting, or relayers work.*
_Behavior: Even advanced users struggled to articulate how features like stealth addresses, shielded voting, or relayers work._
- Ambiguous labels (“Power user,” “Shielded,” “ASP”) created anxiety or alienation.
- Users appreciated inline explanations and step-by-step guidance.
@@ -253,14 +236,12 @@ Privacy Pool UI
**Quotes:**
> “A normal user probably doesnt know what stealth addresses are, even Im not sure I could define it.”
>
![Fluidkey UI](public/articles/privacy-experience-report/Fluidkey UI_3.webp)
Fluidkey UI
> Power user makes me feel like maybe Im not technical enough.”
>
![Fluidkey UI](public/articles/privacy-experience-report/Fluidkey UI_4.webp)
@@ -272,7 +253,7 @@ Fluidkey UI
### **Pattern 8: Desired Qualities in Privacy Tools**
*Behavior: Across all interviews, users consistently valued:*
_Behavior: Across all interviews, users consistently valued:_
- **Transparency:** showing whats happening
- **Control:** ability to verify and customize
@@ -282,32 +263,30 @@ Fluidkey UI
**Quotes:**
> “Anything that makes me feel a little bit more safe is important, like links to audits, social proof.”
>
![Fluidkey UI](public/articles/privacy-experience-report/Fluidkey UI_5.webp)
Fluidkey UI
> “Older apps that have been around longer feel safer.”
>
**Design implication:**
→ Frame privacy as *trustable infrastructure* — emphasizing stability, safety, and proof over abstraction.
→ Frame privacy as _trustable infrastructure_ — emphasizing stability, safety, and proof over abstraction.
## Map to p**ain points vs opportunities and provide design suggestions**
*Summarize the themes we identified as pain points and opportunities*
_Summarize the themes we identified as pain points and opportunities_
| Theme | Core Pain Point | Design Opportunity |
| --- | --- | --- |
| 1. Clarity of privacy scope | Users cant tell whats private | Add visible privacy indicators |
| 2. Trust verification | Users rely on brand, not proof | Include audits and on-chain verifiability |
| 3. Technical friction | Setup is complex | Simplify and guide onboarding |
| 4. Default behaviors | Wrong defaults expose users | Privacy-by-default UI |
| 5. Fear of loss | Lack of testing or visibility | Provide test mode and confirmations |
| 6. Varying privacy motivation | Context-dependent needs | Offer adaptive privacy modes |
| 7. Education & communication | Jargon-heavy UX | Layered explanations, plain language |
| Theme | Core Pain Point | Design Opportunity |
| ----------------------------- | ------------------------------- | ----------------------------------------- |
| 1. Clarity of privacy scope | Users cant tell whats private | Add visible privacy indicators |
| 2. Trust verification | Users rely on brand, not proof | Include audits and on-chain verifiability |
| 3. Technical friction | Setup is complex | Simplify and guide onboarding |
| 4. Default behaviors | Wrong defaults expose users | Privacy-by-default UI |
| 5. Fear of loss | Lack of testing or visibility | Provide test mode and confirmations |
| 6. Varying privacy motivation | Context-dependent needs | Offer adaptive privacy modes |
| 7. Education & communication | Jargon-heavy UX | Layered explanations, plain language |
## **Call to Action: Shaping the Future of On-Chain Privacy**
@@ -316,20 +295,20 @@ This research is an open invitation to the ecosystem. We hope designers, develop
**Contribute to Future Work:**
1. **Identify Technical Challenges**
Many user pain points appear UX-related but are rooted in deep technical limitations. Building verifiable privacy, safe testing, and seamless defaults requires cryptographic innovation, infrastructure evolution, and better developer tooling.
Many user pain points appear UX-related but are rooted in deep technical limitations. Building verifiable privacy, safe testing, and seamless defaults requires cryptographic innovation, infrastructure evolution, and better developer tooling.
2. **Expand Quantitative Understanding**
Complement this qualitative study with large-scale quantitative analysis (Were actively collecting responses at Devconnect! Fill out [the survey here](https://pad.ethereum.org/form/#/2/form/view/IFZv0NuHEXd-eqIBh0o+C88F9V6+WVcBGKEb1d2LJcE/) for us to better understand your perspective on privacy tools). Measure and prioritize privacy needs, attitudes, and usage barriers across user segments. Like technical vs. non-technical, high vs. low privacy motivation, guiding where investment will have the most impact.
Complement this qualitative study with large-scale quantitative analysis (Were actively collecting responses at Devconnect! Fill out [the survey here](https://pad.ethereum.org/form/#/2/form/view/IFZv0NuHEXd-eqIBh0o+C88F9V6+WVcBGKEb1d2LJcE/) for us to better understand your perspective on privacy tools). Measure and prioritize privacy needs, attitudes, and usage barriers across user segments. Like technical vs. non-technical, high vs. low privacy motivation, guiding where investment will have the most impact.
3. **Prototype and Share Solutions**
Pilot “privacy-by-default” interfaces, testnet-safe flows, and verifiable trust cues. Publish learnings openly to accelerate shared progress.
Pilot “privacy-by-default” interfaces, testnet-safe flows, and verifiable trust cues. Publish learnings openly to accelerate shared progress.
4. **Build an Open Privacy UX Community**
If youre a designer, developer, or researcher passionate about privacy experience, contribute ideas, case studies, or experiments. Together, we can make privacy a *default expectation,* but not an afterthought.
If youre a designer, developer, or researcher passionate about privacy experience, contribute ideas, case studies, or experiments. Together, we can make privacy a _default expectation,_ but not an afterthought.
5. **Broaden Role and Feature Coverage**
This study focused on specific user roles and product features. For instance, DAO managers in governance tools or deposit flows in privacy wallets. Future research should explore the full ecosystem of participants and functionalities to provide a more holistic view of the Privacy Experience (PX) across contexts.
This study focused on specific user roles and product features. For instance, DAO managers in governance tools or deposit flows in privacy wallets. Future research should explore the full ecosystem of participants and functionalities to provide a more holistic view of the Privacy Experience (PX) across contexts.

View File

@@ -3,68 +3,61 @@ import matter from "gray-matter"
import jsYaml from "js-yaml"
import path from "path"
// Valid base paths for images in the public folder
const VALID_IMAGE_BASES = ["articles", "projects", "project", "project-banners"]
/**
* Normalizes image paths in markdown content to ensure they resolve correctly.
* Handles various user input formats and converts them to /{basePath}/[slug]/[filename]
* @param content - The markdown content to process
* @param defaultBasePath - The default base path to use if none is detected (e.g., "articles" or "projects")
*/
function normalizeContentImagePaths(
content: string,
defaultBasePath: string = "articles"
): string {
const normalizeImagePath = (imagePath: string): string => {
// Skip external URLs, data URIs, and anchor links
if (
imagePath.startsWith("http://") ||
imagePath.startsWith("https://") ||
imagePath.startsWith("data:") ||
imagePath.startsWith("#")
) {
return imagePath
}
function normalizeImagePath(
imagePath: string | undefined,
defaultBasePath: string = "articles",
slug?: string
): string | undefined {
if (!imagePath) return undefined
let normalized = imagePath.trim()
// Remove leading ./ or ../
normalized = normalized.replace(/^(?:\.\.?\/)+/, "")
// Remove leading /
normalized = normalized.replace(/^\/+/, "")
// Remove public/ prefix (with or without leading slash already handled)
normalized = normalized.replace(/^public\//, "")
// Check if path already starts with a valid base
const hasValidBase = VALID_IMAGE_BASES.some((base) =>
normalized.startsWith(`${base}/`)
)
// If no valid base, prepend the default base path
if (!hasValidBase) {
normalized = `${defaultBasePath}/${normalized}`
}
// Add leading /
return `/${normalized}`
if (
imagePath.startsWith("http://") ||
imagePath.startsWith("https://") ||
imagePath.startsWith("data:") ||
imagePath.startsWith("#")
) {
return imagePath
}
// Match markdown images: ![alt](path) - handles optional title
let normalized = imagePath.trim()
normalized = normalized.replace(/^(?:\.\.?\/)+/, "")
normalized = normalized.replace(/^\/+/, "")
normalized = normalized.replace(/^public\//, "")
const hasValidBase = VALID_IMAGE_BASES.some((base) =>
normalized.startsWith(`${base}/`)
)
if (!hasValidBase) {
const isJustFilename = !normalized.includes("/")
if (isJustFilename && slug) {
normalized = `${defaultBasePath}/${slug}/${normalized}`
} else {
normalized = `${defaultBasePath}/${normalized}`
}
}
return `/${normalized}`
}
function normalizeContentImagePaths(
content: string,
defaultBasePath: string = "articles",
slug?: string
): string {
const markdownImageRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
content = content.replace(markdownImageRegex, (match, alt, imagePath) => {
const normalized = normalizeImagePath(imagePath)
const normalized = normalizeImagePath(imagePath, defaultBasePath, slug)
return `![${alt}](${normalized})`
})
// Match HTML img tags: <img src="path" /> or <img src='path' />
const htmlImageRegex = /(<img\s+[^>]*src=)(["'])([^"']+)\2([^>]*>)/gi
content = content.replace(
htmlImageRegex,
(match, prefix, quote, imagePath, suffix) => {
const normalized = normalizeImagePath(imagePath)
const normalized = normalizeImagePath(imagePath, defaultBasePath, slug)
return `${prefix}${quote}${normalized}${quote}${suffix}`
}
)
@@ -103,7 +96,6 @@ export interface Article {
const articlesDirectory = path.join(process.cwd(), "content/articles")
const projectsDirectory = path.join(process.cwd(), "content/projects")
// Generic function to read and process markdown content from any directory
export function getMarkdownContent<T = any>(options: {
directory: string
excludeFiles?: string[]
@@ -120,23 +112,18 @@ export function getMarkdownContent<T = any>(options: {
return null
}
// Read markdown file as string
const fullPath = path.join(directory, fileName)
const fileContents = fs.readFileSync(fullPath, "utf8")
try {
// Use matter with options to handle multiline strings
const matterResult = matter(fileContents, {
engines: {
yaml: {
// Ensure multiline strings are parsed correctly
parse: (str: string) => {
try {
// Use js-yaml's safe load to parse the YAML with type assertion
return jsYaml.load(str) as object
} catch (e) {
console.error(`Error parsing frontmatter in ${fileName}:`, e)
// Fallback to empty object if parsing fails
return {}
}
},
@@ -144,12 +131,10 @@ export function getMarkdownContent<T = any>(options: {
},
})
// Use custom processor if provided
if (processContent) {
return processContent(matterResult.data, matterResult.content, id)
}
// Default processing - return raw data with content
return {
id,
...matterResult.data,
@@ -157,7 +142,6 @@ export function getMarkdownContent<T = any>(options: {
}
} catch (error) {
console.error(`Error processing ${fileName}:`, error)
// Return minimal content data if there's an error
return {
id,
title: `Error processing ${id}`,
@@ -181,13 +165,11 @@ export function getArticles(options?: {
directory: articlesDirectory,
excludeFiles: ["readme", "_readme", "_article-template"],
processContent: (data, content, id) => {
// Normalize tags from both 'tags' and 'tag' fields
const rawTags = [
...(Array.isArray(data?.tags) ? data.tags : []),
...(data?.tag ? [data.tag] : []),
]
// Process and normalize tags
const normalizedTags = rawTags
.map((tag) => {
if (typeof tag !== "string") return null
@@ -206,15 +188,15 @@ export function getArticles(options?: {
return {
id,
...data,
image: normalizeImagePath(data.image, "articles", id),
tags: normalizedTags,
content: normalizeContentImagePaths(content, "articles"),
content: normalizeContentImagePaths(content, "articles", id),
}
},
})
let filteredArticles = allArticles
// Filter by tag if provided
if (tag) {
const tagId = tag.toLowerCase()
filteredArticles = filteredArticles.filter((article) =>
@@ -222,7 +204,6 @@ export function getArticles(options?: {
)
}
// Filter by project if provided
if (project) {
filteredArticles = filteredArticles.filter(
(article) =>
@@ -230,13 +211,10 @@ export function getArticles(options?: {
)
}
// Sort posts by date
return filteredArticles
.sort((a, b) => {
const dateA = new Date(a.date)
const dateB = new Date(b.date)
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime()
})
.slice(0, limit)
@@ -255,21 +233,19 @@ export function getProjects(options?: {
processContent: (data, content, id) => ({
id,
...data,
content: normalizeContentImagePaths(content, "projects"),
image: normalizeImagePath(data.image, "projects", id),
content: normalizeContentImagePaths(content, "projects", id),
}),
})
// Filter by tag if provided
if (tag) {
allProjects = allProjects.filter((project) => project.tags?.includes(tag))
}
// Filter by status if provided
if (status) {
allProjects = allProjects.filter((project) => project.status === status)
}
// Apply limit if provided
if (limit && limit > 0) {
allProjects = allProjects.slice(0, limit)
}
@@ -288,7 +264,6 @@ export const getArticleTags = () => {
return acc
}, [])
// Sort tags alphabetically by name
return allTags.sort((a, b) => a.name.localeCompare(b.name))
}