Passa al contenuto principale

Refactoring UI

Anteprima del progetto in cui metto in pratica i principi di Refactoring UI

Refactoring UI in Pratica

Vai al Progetto ↗

Componenti copiabili,
zero dipendenze

Il Progetto

Un caso studio pratico sul libro Refactoring UI di Adam Wathan e Steve Schoger: ogni componente è costruito traducendo direttamente un principio del libro in codice. Nessun framework, nessun build step, tre semplici file: index.html, styles.css, script.js.

Codice Sorgente

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Refactoring UI Study | UX Engineer Log</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&display=swap">
<link rel="stylesheet" href="styles.css">
</head>

<body>

<!--
DESIGN
------
* This is a single-page component showcase built to explore and apply the concepts
* learned from Refactoring UI (Adam Wathan and Steve Schoger).
* It has no build step, just three plain files: this one, styles.css, and script.js
* The architecture follows a strict separation of concerns:
* - index.html (this file) contains only the structure and content
* - styles.css handles every visual rule, token, and state
* - script.js implements the copy-to-clipboard behavior and checkmark animation
*
* SVG SPRITE PATTERN (<symbol id="icon-copy">):
* - A "sprite" acts as a hidden catalog of reusable graphics.
* Here, I define the copy icon's path only once at the top of the <body>,
* inside a visually hidden <svg> definition
* - Maintainability and performance: All 16 copy buttons reference this single
* definition via <use href="#icon-copy">. This keeps the DOM clean and
* creates a Single Source of Truth. If the icon design changes, I only
* need to update that one hidden symbol
* - Coordinate space stability: The outer <svg> wrapper on each button strictly
* enforces viewBox="0 0 26 26". This guarantees a stable drawing canvas,
* ensuring the icon never warps or shifts, even when JavaScript dynamically
* swaps the SVG content (from the copy icon to the success checkmark)
*
* ACCESSIBILITY:
* - Hidden labels: Every input has a <label for="..."> with class="sr-only".
* While sighted users understand the input's purpose from the visual layout,
* screen readers need real text. The 'for' attribute links the label to the
* input so blind users know exactly what the field is for
* - Semantic grouping: The radio buttons are wrapped in a <fieldset> with a
* hidden <legend>. Without this, a screen reader would just announce the
* options without explaining the main topic. The <legend> acts as a title
* for the group, telling the user exactly what choice they are making
* - Decorative elements: SVGs used only for visual appeal carry aria-hidden="true".
* Without this, screen readers might announce meaningless "image" or "graphic"
* labels. This attribute hides the icon from the accessibility tree, eliminating
* audio clutter and keeping the focus on the actual text or action
* - Contextual actions: Every copy button has a unique aria-label (e.g.
* "Copy primary button color"). If we just used "Copy" for all 16 buttons,
* a blind user tabbing through the page wouldn't know what they are copying
*
* PAGE STRUCTURE:
* 0. STYLESHEET: components.css preview and one-click copy
* 1. BUTTON: Primary, Secondary, Tertiary
* 2. INPUT: Alone, With Checkbox
* 3. RADIO BUTTON
* 4. CARD: 5 shadow levels
* 5. ICON: Original (128px), Simplified (128px), Shrunk (16px in frame)
* 6. EMPTY STATE
-->

<!--
Shared SVG sprite:
Defines the copy icon paths once to keep the DOM clean. All copy buttons
reference it dynamically via <use href="#icon-copy">. While 'display: none'
removes it from the visual layout, 'aria-hidden="true"' acts as a defensive
fallback to ensure screen readers never announce it
-->
<svg aria-hidden="true" style="display:none">
<defs>
<symbol id="icon-copy" viewBox="0 0 26 26" fill="none">
<path d="M22.6 8.20001H10.6C9.2745 8.20001 8.19998 9.27453 8.19998 10.6V22.6C8.19998 23.9255 9.2745 25 10.6 25H22.6C23.9255 25 25 23.9255 25 22.6V10.6C25 9.27453 23.9255 8.20001 22.6 8.20001Z"
stroke="var(--copy-icon-stroke)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.4 17.8C2.08 17.8 1 16.72 1 15.4V3.4C1 2.08 2.08 1 3.4 1H15.4C16.72 1 17.8 2.08 17.8 3.4"
stroke="var(--copy-icon-stroke)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

<main class="main-container">

<!-- STYLESHEET PREVIEW -->
<section class="flex-col items-start" style="gap: var(--space-15); width: var(--container-width);">
<div class="flex-col items-start" style="gap: var(--space-3);">
<h2 class="section-title">STYLESHEET</h2>
<p class="stylesheet-desc">Contains all the styles for every component on this page. Copy once and drop it into your project.</p>
</div>
<div class="flex-col items-center" style="gap: var(--space-7); width: 100%;">
<div class="css-preview">
<div class="css-preview-header">
<span class="css-preview-filename">components.css</span>
</div>
<pre class="css-preview-code"><span class="syntax-keyword">@import</span> <span class="syntax-token">url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&amp;display=swap')</span>;

<span class="syntax-comment">/* ... DESIGN block: usage guide and customization instructions ... */</span>

<span class="syntax-keyword">:root</span> {

<span class="syntax-comment">/* COLORS - BACKGROUND */</span>
<span class="syntax-token">--bg-main</span>: <span class="syntax-token">hsl(30, 26%, 84%)</span>;

<span class="syntax-comment">/* COLORS - PRIMARY ACTION */</span>
<span class="syntax-token">--primary-bg</span>: <span class="syntax-token">hsl(32, 25%, 29%)</span>;
<span class="syntax-token">--primary-text</span>: <span class="syntax-token">hsl(0, 0%, 100%)</span>;</pre>
<div class="css-preview-fade"></div>
</div>
<!--
JS hook: Instead of relying on fragile CSS classes, 'data-component' acts as a
robust bridge. It passes the exact object key (e.g., "css", "btn-primary")
to our script, allowing a single JavaScript function to handle all 16 buttons
-->
<button class="btn-copy" aria-label="Copy stylesheet" data-component="css">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</section>

<!-- SECTION 1: BUTTONS -->
<section class="flex-col items-start" style="gap: var(--space-20); width: var(--container-width);">
<h2 class="section-title">BUTTON</h2>

<div class="flex-row items-start justify-between components-row" style="width: 100%;">
<!-- Primary -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">PRIMARY</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<button class="btn-primary">Confirm</button>
<button class="btn-copy" aria-label="Copy primary button" data-component="btn-primary">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Secondary -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">SECONDARY</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<button class="btn-secondary">Review</button>
<button class="btn-copy" aria-label="Copy secondary button" data-component="btn-secondary">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Tertiary -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">TERTIARY</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<button class="btn-tertiary">Cancel</button>
<button class="btn-copy" aria-label="Copy tertiary button" data-component="btn-tertiary">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>
</div>
</section>

<!-- SECTION 2: INPUT -->
<section class="flex-col items-start" style="gap: var(--space-20); width: var(--container-width);">
<h2 class="section-title">INPUT</h2>

<div class="flex-row items-start justify-between components-row" style="width: 100%;">
<!-- Alone -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">ALONE</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<label for="input-alone" class="sr-only">Details</label>
<input type="text" id="input-alone" class="input-alone" placeholder="Add optional details...">
<button class="btn-copy" aria-label="Copy input" data-component="input">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- With Checkbox -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">WITH CHECKBOX</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="flex-col items-start" style="gap: var(--space-3);">
<label for="input-with-cbx" class="sr-only">Details</label>
<input type="text" id="input-with-cbx" class="input-alone" placeholder="Add optional details...">
<label class="flex-row items-center" style="gap: var(--space-3); cursor: pointer;">
<input type="checkbox" class="cbx-custom sr-only">
<div class="cbx-box"></div>
<span class="cbx-label">I understand this will submit</span>
</label>
</div>
<button class="btn-copy" aria-label="Copy input with checkbox" data-component="input-checkbox">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>
</div>
</section>

<!-- SECTION 3: RADIO BUTTON -->
<section class="flex-col items-start section-content" style="gap: var(--space-15);">
<h2 class="section-title">RADIO BUTTON</h2>
<div class="flex-col items-center" style="gap: var(--space-7);">
<fieldset>
<legend class="sr-only">Sync options</legend>
<div class="flex-col justify-center items-start" style="gap: var(--space-3);">
<!-- Radio 1 -->
<label class="flex-row items-center" style="gap: var(--space-3); cursor: pointer;">
<input type="radio" name="sync" class="native-radio sr-only" checked>
<svg class="custom-radio-svg" xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="18" stroke="var(--radio-stroke)" stroke-width="4" />
<circle cx="20" cy="20" r="12" fill="var(--radio-stroke)" class="radio-dot" />
</svg>
<span class="radio-label-text">Sync automatically</span>
</label>
<!-- Radio 2 -->
<label class="flex-row items-center" style="gap: var(--space-3); cursor: pointer;">
<input type="radio" name="sync" class="native-radio sr-only">
<svg class="custom-radio-svg" xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="18" stroke="var(--radio-stroke)" stroke-width="4" />
<circle cx="20" cy="20" r="12" fill="var(--radio-stroke)" class="radio-dot" />
</svg>
<span class="radio-label-text">Manual sync only</span>
</label>
</div>
</fieldset>
<button class="btn-copy" aria-label="Copy radio buttons" data-component="radio">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</section>

<!-- SECTION 4: CARD -->
<section class="flex-col items-start section-content" style="gap: var(--space-15);">
<h2 class="section-title">CARD</h2>

<div class="flex-col items-start cards-outer" style="gap: var(--space-10);">
<!-- Row 1 -->
<div class="flex-row items-start cards-row-1" style="gap: var(--space-8);">
<!-- Card 1 -->
<div class="flex-col items-center" style="gap: var(--space-7);">
<h3 class="component-label">1</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="card card-1" aria-hidden="true"></div>
<button class="btn-copy" aria-label="Copy card level 1" data-component="card-1">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Card 2 -->
<div class="flex-col items-center" style="gap: var(--space-7);">
<h3 class="component-label">2</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="card card-2" aria-hidden="true"></div>
<button class="btn-copy" aria-label="Copy card level 2" data-component="card-2">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Card 3 -->
<div class="flex-col items-center" style="gap: var(--space-7);">
<h3 class="component-label">3</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="card card-3" aria-hidden="true"></div>
<button class="btn-copy" aria-label="Copy card level 3" data-component="card-3">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>
</div>

<!-- Row 2 -->
<div class="cards-row-center" style="gap: var(--space-8);">
<!-- Card 4 -->
<div class="flex-col items-center" style="gap: var(--space-7);">
<h3 class="component-label">4</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="card card-4" aria-hidden="true"></div>
<button class="btn-copy" aria-label="Copy card level 4" data-component="card-4">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Card 5 -->
<div class="flex-col items-center" style="gap: var(--space-7);">
<h3 class="component-label">5</h3>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="card card-5" aria-hidden="true"></div>
<button class="btn-copy" aria-label="Copy card level 5" data-component="card-5">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>
</div>
</div>
</section>

<!-- SECTION 5: ICON -->
<section class="flex-col items-start" style="gap: var(--space-20); width: var(--container-width);">
<h2 class="section-title">ICON</h2>

<div class="flex-row items-start justify-between components-row" style="width: 100%;">
<!-- Original -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">ORIGINAL</h3>
<span class="sr-only">Shows the base icon design at 128 pixels with thin strokes, meant for large display.</span>
<div class="flex-col items-center" style="gap: var(--space-7);">
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"
fill="none" aria-hidden="true">
<rect width="128" height="128" rx="20" fill="url(#paint0_linear_40_145)" />
<path d="M77.0004 83.5006L96.5011 63.9999L77.0004 44.4993" stroke="#FFF9F2" stroke-width="8"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M50.9996 44.4993L31.4989 63.9999L50.9996 83.5006" stroke="#FFF9F2" stroke-width="8"
stroke-linecap="round" stroke-linejoin="round" />
<defs>
<linearGradient id="paint0_linear_40_145" x1="64" y1="128" x2="64" y2="0"
gradientUnits="userSpaceOnUse">
<stop stop-color="#7A6249" />
<stop offset="1" stop-color="#C29A6F" />
</linearGradient>
</defs>
</svg>
<button class="btn-copy" aria-label="Copy original icon" data-component="icon-original">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Simplified -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">SIMPLIFIED</h3>
<span class="sr-only">Shows the icon at 128 pixels but intentionally simplified with thicker internal lines to prepare for downscaling.</span>
<div class="flex-col items-center" style="gap: var(--space-7);">
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"
fill="none" aria-hidden="true">
<rect width="128" height="128" rx="20" fill="url(#paint0_linear_40_155)" />
<path d="M80.9159 89.3738L106.29 64L80.9159 38.6262" stroke="#FFF9F2" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M47.0841 38.6262L21.7103 64L47.0841 89.3738" stroke="#FFF9F2" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" />
<defs>
<linearGradient id="paint0_linear_40_155" x1="64" y1="128" x2="64" y2="0"
gradientUnits="userSpaceOnUse">
<stop stop-color="#7A6249" />
<stop offset="1" stop-color="#C29A6F" />
</linearGradient>
</defs>
</svg>
<button class="btn-copy" aria-label="Copy simplified icon" data-component="icon-simplified">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>

<!-- Shrunk -->
<div class="flex-col items-center" style="gap: var(--space-15);">
<h3 class="component-label">SHRUNK</h3>
<span class="sr-only">Shows the simplified icon scaled down to 16 pixels for a favicon. The thicker lines ensure it remains perfectly legible at this tiny size.</span>
<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="icon-frame">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="none" aria-hidden="true">
<rect width="16" height="16" rx="2.5" fill="url(#paint0_linear_40_165)" />
<path d="M10.1145 11.1718L13.2862 8.0001L10.1145 4.82837" stroke="#FFF9F2"
stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.88553 4.82837L2.71381 8.0001L5.88553 11.1718" stroke="#FFF9F2"
stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
<defs>
<linearGradient id="paint0_linear_40_165" x1="8" y1="16" x2="8" y2="0"
gradientUnits="userSpaceOnUse">
<stop stop-color="#7A6249" />
<stop offset="1" stop-color="#C29A6F" />
</linearGradient>
</defs>
</svg>
</div>
<button class="btn-copy" aria-label="Copy shrunk icon" data-component="icon-shrunk">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</div>
</div>
</section>

<!-- SECTION 6: EMPTY STATE -->
<section class="flex-col items-start section-content" style="gap: var(--space-15);">
<h2 class="section-title">EMPTY STATE</h2>

<div class="flex-col items-center" style="gap: var(--space-7);">
<div class="flex-col items-start" style="gap: var(--space-11);">
<div class="flex-col items-start" style="gap: var(--space-3);">
<h3 class="title-empty">Ready when you are</h3>
<p class="subtitle-empty">Everything you create will show up here.</p>
</div>
<button class="btn-primary">New item</button>
</div>
<button class="btn-copy" aria-label="Copy empty state" data-component="empty-state">
<svg width="16" height="16" viewBox="0 0 26 26" fill="none" aria-hidden="true"><use href="#icon-copy"/></svg>
</button>
</div>
</section>

</main>

<!--
Dynamic audio feedback:
- The problem: When JavaScript injects a success message ("Copied!") into the DOM,
screen readers ignore it unless the element receives focus. A "live region"
forces the software to monitor this <div> and announce any text changes
- Polite vs. assertive: 'aria-live="polite"' tells the screen reader to finish
its current sentence before speaking. It gracefully respects the user's flow
- Why not assertive? 'aria-live="assertive"' violently interrupts and cuts off
the current audio. It should be strictly reserved for critical errors or
time-sensitive warnings (e.g. "Session expired"), never for simple success states
-->
<div id="copy-status" aria-live="polite" class="sr-only"></div>

<script src="script.js"></script>
</body>

</html>

Il Libro

È giunto il momento di parlare del libro che più di tutti ha migliorato e accelerato il mio percorso. L'ho vissuto in due fasi.

La prima quando lavoravo in fabbrica: lo leggevo in mensa e in tutte le pause disponibili, riuscivo a leggere ed approfondire 15 pagine al giorno, come scrissi nel progetto Balance Sheet, nel quale mi trovavo nel vivo di quel momento. Avevo letto il 60% in quei mesi. All'inizio del percorso avevo fatto una scaletta dei libri da leggere e, nonostante il Code Tutor mi avesse suggerito di leggerlo più avanti, ero troppo curioso per aspettare. Leggerne la prima parte all'inizio mi ha fatto evitare fin da subito gli errori classici di chi inizia: spaziature date a caso, proporzioni anch'esse casuali, gerarchie visive affidate solo alle dimensioni del font invece che al peso e alla luminosità, e quel riflesso condizionato di rendere qualsiasi azione distruttiva rossa e minacciosa anche quando non lo è.

La seconda fase è stata ora che ho lasciato il lavoro: in due giorni ho finito la parte rimanente. Ho adottato lo stesso approccio di sempre, fare domande, andare oltre, "contrattaccare" ciò che veniva insegnato e farmi "contrattaccare" di conseguenza. Ho preso molti appunti ed aggiunto tutto nel Vademecum UX & UI, non quello reperibile qui sul sito ma il mio privato, che purtroppo non posso pubblicare perché violerebbe la proprietà intellettuale di molte risorse sulle quali ho studiato.

Come è Nato il Progetto

Il Primo Tentativo (Cestinato)

Non mi sono ispirato a niente di già esistente per la creazione di questo progetto.
Immaginavo da qualche giorno l'interfaccia con chiarezza: pulsanti piccoli per copiare il codice, un'icona di copia non troppo evidente in ogni componente per non disturbare con la sua ripetizione, un tema che rispecchiasse questo stesso sito. In questo progetto il Code Tutor si è tramutato in Claude Code Tutor: l'avevo usato per pochi esperimenti in precedenza, ma questa è la prima volta che l'ho usato seriamente.

La prima versione l'avevo configurata in ambiente Vite + React. Ho fatto una cosa di cui mi sono pentito subito dopo: una volta decisi gli argomenti da trattare, ho iniziato a dare le specifiche direttamente a Claude Code, chiedendogli di implementare tutto basandosi sugli appunti che avevo preso sul libro, pur fornendogli specifiche precise sul risultato atteso. Il risultato fu pessimo, nonostante diverse iterazioni:

Screenshot della prima versione del caso studio di Refactoring UI

Con questo non voglio dire che lo strumento sia pessimo: avrei potuto usare prompt ancora più specifici. Ma non era solo quello il problema. Sentivo che alla lunga non avrei contribuito per niente, creando un progetto dove avevo contribuito solo con istruzioni e appunti, nulla di più; e, non meno importante, mi stavo togliendo la parte più gratificante di questo lavoro. A ciò si aggiungeva la complessità che percepivo come totalmente inutile e la repulsione che provo ancora per le librerie esterne. Non trovavo soprattutto senso nell'approccio, dato che l'obiettivo era semplicemente applicare i concetti più utili emersi dalla lettura. Avrei potuto farlo in React, dato che avevo appena finito il Superhero Application Form, e quindi non partendo da zero, ma ho preferito tornare alle basi.

Ho buttato tutto nel cestino: mezza giornata di lavoro.

Figma Prima, Codice Dopo

Ho aperto Figma e ho disegnato tutto da zero. Ora che avevo visto il risultato che non volevo, sapevo esattamente cosa volevo.
Ho adottato lo stesso approccio che applicavo nella chat web: un processo iterativo basato su una pianificazione condivisa. Al posto di scrivere "non scrivere ancora una riga di codice" in chat, come facevo prima, ora lo scrivevo nel terminale, il principio che ho usato è stato: nessuna implementazione finché non avevo una pianificazione condivisa e approvata, al fine di azzerare il debito tecnico che sarebbe inevitabilmente emerso con soluzioni non pensate fin dall'inizio.
Sono partito dal mockup Figma, che è molto simile alla versione implementata sul sito. Le differenze che si notano riguardano (al di là della mancanza del css preview) quasi esclusivamente le dimensioni, problematiche emerse quando quei valori si sono tramutati in CSS.

Non sarei mai riuscito a farlo senza rendere concreta quella visione. Ho fatto mia la frase degli autori:

Don't get overwhelmed working in the abstract. Build the real thing as early as possible so your imagination doesn't have to do all the heavy lifting.
~ Adam Wathan e Steve Schoger

Screenshot della versione Figma del caso studio di Refactoring UI

Ho poi estratto ogni singola proprietà da tutti i 15 componenti da Figma tramite la Dev Mode, riportandola in un file .txt con le specifiche di titoli, sottotitoli e spaziature.

Claude Code

Un problema che dovevo risolvere era il file CSS: come permettere all'utente di copiarlo interamente con un click? Così che sarebbe stato lui libero di incollarlo nel file che avrebbe desiderato senza trovarsi obbligato a partire dal mio? Volevo un componente specifico, una sorta di preview in alto per copiare il CSS. È l'elemento a cui ho contribuito solo con l'idea: Claude Code lo ha realizzato benissimo al primo tentativo. Abbiamo poi sistemato tutto il lato responsive del sito. Ho testato su iPhone 15 Pro, che ho comprato di recente per testare la fattibilità tecnica di Mosaic, iPhone 12 mini e Honor Magic V2 (il foldable), incluso il landscape: il sito è navigabile perfettamente anche da un telefono piccolo come il 12 mini in modalità landscape.

Ci sono stati alcuni problemi nella fase di implementazione che mi hanno aperto scenari così interessanti che finivo per approfondire i diversi concetti invece di andare avanti. Un esempio su tutti: inizialmente avevo provato a caricare il components.css dinamicamente con fetch() invece di incorporarlo nel file JS, ma ho scoperto due problemi fondamentali. Il primo: fetch() è bloccato sul protocollo file://, il browser tratta i file locali come origini non attendibili e rifiuta la richiesta. Il secondo, più sottile: anche quando servito normalmente, concatenare fetch() prima di writeText() rompe l'accesso agli appunti. Il browser permette la scrittura nella clipboard solo immediatamente dopo un click dell'utente, e nel momento in cui fetch() risolve e raggiunge writeText(), quella finestra si è già chiusa. La soluzione è stata incorporare tutto come template literal direttamente in script.js.

Ho provato come alternativa anche Antigravity, ma dopo 2-3 errori che si ripercuotevano sulla UI sono tornato a Claude Code. Per tutti i task ho usato Sonnet; Opus l'ho usato solo per due check complessivi per confrontare con gli standard di produzione, dandomi punteggi nelle varie aree nel corso del progetto, scegliendo quindi attivamente cosa risolvere e cosa lasciare considerando la natura di questo progetto.

Chat Web vs Agente

Inizialmente pensavo che la chat web fosse, almeno in questa fase di apprendimento, la scelta migliore. Mi sbagliavo. Nella chat web avevo compiti aggiuntivi che si tramutavano in frizioni. Con Claude Code ero nel flusso più totale, e appena ho capito che prima di ogni modifica dovevo farmi elencare le opzioni disponibili, sviscerarle e capirne pro e contro prima di toccare il codice, ho capito che non stavo perdendo nulla rispetto a ciò che facevo nella chat web.
Rimango comunque combattuto su un punto: quella frizione di copiare manualmente o riscrivere di proprio pugno il blocco di codice dalla chat, forse non era un processo che consolidava il concetto appena spiegato. Era semplicemente un compito ripetitivo a cui il cervello non prestava più attenzione e svolgeva col pilota automatico, o almeno così mi sembra guardando indietro. Il prezzo che probabilmente pagherò in futuro è il dimenticarmi la sintassi.

Detto ciò, sono convinto, come ho imparato nel video di Raffaele Gaito nel quale il docente universitario Vittorio Scarano parla di responsabilità nell'uso degli strumenti AI, che l'uso di questi strumenti ricadrà sempre su chi li ha usati. Nessuno può dire "ha sbagliato Claude": hai sbagliato tu perché ti sei fidato di quell'output senza capirlo, comprenderlo e verificare se esistessero alternative più efficienti e sicure. Penso che sia un concetto per nulla scontato, seppur a posteriori sembri ovvio.

Le Scelte di Design

Bottoni

Ho deciso di non fare il classico pulsante che passa dal primary al tertiary con lo stesso testo, come viene mostrato nella maggior parte dei materiali didattici, ma di assegnare testi diversi: Confirm per il primary, Review per il secondary e Cancel per il tertiary. Questo per essere più efficiente in termini di messaggio: il primary contiene la call to action, il secondary con "Review" sottintende un pulsante per rivedere qualcosa di precedentemente scritto (una recensione, un ordine su un e-commerce), mentre il tertiary, controintuitivamente, è Cancel.

Perché Cancel nel tertiary? Refactoring UI insegna che un'azione distruttiva non deve per forza essere rossa ed evidente se non è l'azione primaria. Se lo fosse, allora sì: rossa, non accecante, ma con lo stile primary ben visibile. Nei casi in cui non lo fosse, ha senso trattarla come un pulsante qualunque. Ho trovato questo concetto rivoluzionario: nei corsi che avevo frequentato (Talent Garden, Google UX), l'azione distruttiva era vista come un pericolo da sbandierare, mentre invece no, o perlomeno non in tutti i casi.

Nel primary ho aggiunto un sottile inset box-shadow bianco traslucido sul bordo superiore per simulare la luce che colpisce il bordo di un oggetto fisico che esce dalla pagina. Il secondary usa un min-width leggermente inferiore al primary (128px vs 140px), questo perché ho scoperto che quando i due appaiono fianco a fianco, la differenza di larghezza accentua ulteriormente la gerarchia visiva tra i due, guidando quindi l'occhio verso l'azione principale. Il tutto senza che l'utente se ne accorga.

Input

Ho usato due box-shadow: una inset che scende dall'alto per simulare la profondità, e una esterna sul bordo inferiore che simula il riflesso della luce. Il libro sostiene che l'input deve comunicare che è un contenitore interno dove l'utente scrive, un effetto "cavità" che è l'opposto fisico del bottone che invece esce dallo schermo. La versione WITH CHECKBOX ha il checkbox con lo stesso border-radius proporzionale della casella di input: 6px su 20px fa il 30%, contro 16px su 56px che fa il ~29%. Il checkmark è inserito nel CSS come SVG in data URI, con il colore del segno (#D5C7B8) che corrisponde esattamente al background dell'input: quando il checkbox diventa pieno, il segno sembra fisicamente scavato nel colore.

Radio Button

Ho inserito un solo tipo di radio button, perché l'obiettivo era comunicare che non bisogna usare quelli nativi del browser, ma renderli coerenti con la UI dell'app tramite pochi stili mirati.

Card

Ho usato 5 livelli di ombra distinti, ognuno con due layer sovrapposti: uno più netto che simula la luce diretta dall'alto, uno più diffuso che simula la luce ambientale. Il y-offset raddoppia ad ogni livello (1px, 2px, 4px, 8px, 16px) per creare una progressione di profondità prevedibile e matematicamente coerente.

Icon

Ho inserito le 3 versioni come fa Refactoring UI, per mostrare che il logo interno all'icona va "inspessito" per rimanere riconoscibile nelle dimensioni ridotte del tab del browser.
L'icona originale ha un stroke-width di 8px, quella semplificata di 14px, hanno quindi la stessa forma ma con tratti quasi doppi.

Questo principio l'ho messo subito in pratica dopo averlo letto, creando l'icona di questo stesso sito: le lettere UX con tratti molto spessi per restare leggibili anche a 16px nel tab del browser.

Empty State

Un semplice Empty State con il primary button già creato in precedenza, per ricordare che deve esserci sempre un invito all'azione quando non ci sono dati da mostrare.

Cosa Ho Imparato

Architettura e Accessibilità

Desktop-first basato sul target: Ho applicato qui la nota che avevo scritto nel Telephone Number Validator: "dovremmo valutare in base al dispositivo che verrà utilizzato prevalentemente". Questo è un tool di confronto UI pensato per sviluppatori davanti a uno schermo grande. Costruirlo mobile-first avrebbe rispettato la regola, ma ignorato l'utente reale. Ho scelto consapevolmente desktop-first, gestendo il mobile come fallback.

Grigi tinti vs grigi neutri: Uno dei concetti più specifici del libro applicati nel codice. Ogni "grigio" del progetto ha saturazione al hue 31° per armonizzare con il background a 30°. Un grigio neutro puro (0% saturazione) apparirebbe visivamente scollegato dal resto della palette, come un elemento importato da un'altra UI.

Hit area senza disturbare il layout: L'icona di copia è visivamente 16×16px, ma l'area cliccabile è 44×44px tramite padding: 14px. Per mantenere la spaziatura visiva invariata, un margin-top: -14px compensa esattamente il padding aggiunto. Il componente occupa lo stesso spazio visivo di prima, ma è molto più semplice da toccare su mobile.

Cap-height alignment: Il radio SVG è dichiarato width="24" nell'HTML come fallback, ma CSS lo sovrascrive a 20px. A 18px di font-size, 20px allinea il controllo esattamente all'altezza delle lettere maiuscole del testo accanto. È il criterio corretto per allineare icone inline con tipografia, non il font-size stesso.

JavaScript e DOM

data-component come bridge robusto: Invece di leggere quale snippet copiare dalle classi CSS del bottone, ogni bottone porta un attributo data-component che mappa esattamente una chiave nell'oggetto SNIPPETS. HTML e JS comunicano tramite attributi semantici dedicati, non tramite classi che hanno già un altro scopo. Se le classi CSS cambiano per motivi di stile, la logica di copia non si rompe.

fetch() e la finestra del click: Il browser permette la scrittura nella clipboard solo immediatamente dopo un'azione dell'utente. fetch() è asincrono: nel momento in cui risolve, quella finestra si è già chiusa e writeText() viene rifiutato. La soluzione non è aggirare il browser, ma non allontanarsi mai dal click: il contenuto da copiare deve essere già in memoria nel momento esatto del click.

execCommand come fallback: navigator.clipboard.writeText() è bloccato su file:// e in alcuni contesti mobile Safari. Il fallback crea un <textarea> invisibile fuori dal flusso, lo seleziona e innesca il comando di sistema. È deprecato, ma ho scoperto essere l'opzione più affidabile in questi edge case specifici.

SVG Sprite Pattern: Definire l'icona una volta sola come <symbol> nascosto e referenziarla ovunque tramite <use href="#icon-copy"> mantiene il DOM pulito e crea un'unica Source of Truth: quando JavaScript sostituisce il contenuto dell'SVG per mostrare il checkmark, lo fa in un solo punto invece di aggiornare 16 elementi separati. Se il design dell'icona cambia, si tocca un solo punto nel codice.

CSS e Interazioni

opacity: '' invece di opacity: '1': Dopo l'animazione di copia, reimpostare svg.style.opacity = '1' lascia un valore inline che sovrascrive silenziosamente il CSS hover. Il risultato: l'hover smette di funzionare dopo il primo click, senza nessun errore in console. Rimuovere l'attributo inline con '' restituisce il controllo allo stylesheet.

pointer-events: none sul gradient fade: Il gradiente che sfuma il codice CSS nella preview è in position: absolute sopra il testo. Senza pointer-events: none bloccherebbe tutti i click e le selezioni nell'area sottostante: un problema invisibile che non genera nessun errore, difficile da trovare senza sapere dove cercare.

Transizioni asimmetriche: La transizione sul base rule (la regola CSS che si applica all'elemento a riposo, senza pseudo-classi attive) controlla la velocità di ritorno allo stato normale (160ms, fluido come una molla). La transizione sull':active controlla la velocità di pressione (60ms, quasi istantanea). Il motivo per cui funziona: CSS applica la transizione dallo stato che si sta lasciando. Premendo, si lascia il base state e scatta il 60ms. Rilasciando, si lascia l':active e scatta il 160ms.

display: contents nel carousel mobile: Per trasformare la griglia di card in un carousel orizzontale su mobile, i wrapper .cards-row-1 e .cards-row-center vengono dissolti con display: contents. Questo appiattisce il DOM: i figli bypassano il loro genitore diretto e diventano flex item del container carousel. Senza questo, i wrapper avrebbero interrotto la catena flex e rotto il layout.

{ passive: true } sui touch listener: Aggiungere passive: true agli event listener di touchstart e touchend comunica esplicitamente al browser che il handler non chiamerà mai preventDefault(). Il browser può quindi avviare lo scroll immediatamente senza aspettare che il JavaScript finisca, eliminando il ritardo percepibile su mobile.

btn.blur() per iOS: Dopo la copia, chiamare btn.blur() rilascia lo stato :active su iOS, che altrimenti resterebbe attivo visivamente fino al prossimo tap su un altro elemento. Vale sia in caso di successo che di errore della clipboard.

prefers-reduced-motion con 0.01ms: Usare 0ms per azzerare le transizioni causa un bug in alcuni browser: i listener JavaScript in attesa dell'evento transitionend non scattano mai, bloccando la logica che dipende da quel callback. 0.01ms è abbastanza corto da essere impercettibile, ma abbastanza lungo da far scattare l'evento correttamente.

Riflessione

Nel precedente progetto su The Design of Everyday Things avevo scritto che se un utente nota un mio bottone per la sua estetica ho fatto un lavoro grafico, ma se lo clicca senza pensarci ho fatto un lavoro di ingegneria. Refactoring UI è stato per me il manuale di quell'ingegneria.
Se Norman mi ha insegnato la psicologia dell'interazione e il perché eliminare i carichi cognitivi, questo libro mi ha dato la matematica e il come per scriverla in CSS.
Il mio compito rimane sempre quello di non stupire l'occhio e di liberare la mente. Ma se prima sapevo solo che dovevo rispondere alla domanda dell'utente prima ancora che venisse formulata, ora ho finalmente le formule, i token e il codice per costruire quella risposta.


Next: Leggere "Show Your Work!" di Austin Kleon, ho scoperto che insegna molti dei pattern che ho usato in questo percorso.