The six decisions
Front end work is mostly a small set of choices made early, in a hurry, that everything afterward has to live with. Each panel below takes one of them, with the claim behind it and what it costs when it is skipped. Nothing here needs a framework; the shapes are the same in Angular, React, Vue or a page of plain HTML.
- Design approach to vertical slices Start where you are least sure, at the screen or at the rules behind it, so an unknown blocker shows up while you can still pivot.
- One idea per page What main point do they want this page to ask or tell the user, and what is their call to action?
- Component architecture One job per component, props in, events out, composition over flags.
- State management Where each piece of memory lives, who owns it, and when it is released.
- Applying CSS with AI Which rules are global and which are scoped, and how to tell a model so a fix for one component does not restyle the app.
- Accessibility Constraints that come before the layout, not a checklist after it.
1. Design Approach to Vertical Slices
Ask which end you would be guessing at, the screen or the rules behind it. Every feature is built on two surfaces, the screen the user touches and the rules that sit behind it, and whatever you do not know yet is hiding on one of the two. Start where your confidence is lowest, because that is where the blocker is, and a blocker found late costs a rewrite instead of a decision.
This is a decision about one feature, not an architecture for the whole app. It answers which end of one screen or one flow to touch first, this week. How the application is structured is settled once, early, and revisited rarely, which is a different argument from this one.
Front end first: build the screen
Start here when the screen is what you cannot picture, whether that is the flow, the states, the wording or what the user sees first. A rough screen on a real route settles that faster than another meeting, and it makes the missing pieces obvious.
What it catches is a flow that needs data nobody stored, a state nobody designed, or a second page hiding inside the first. The cost is a screen that stays a sketch, with parts of it moving again once the real rules arrive.
Back end first: prove the rules
Start here when the rules are what you cannot picture, whether that is the data, the contract, who may see a row or whether the query holds at the size of the real data. A query or a stubbed call settles it without designing anything around it.
What it catches is a rule that cannot be enforced, a field nobody collects, or an integration that will not return what the screen needs. The cost is an interface left unpolished while the rules are being proven.
When both ends are unknown, start where a wrong answer costs more. A rule that was guessed wrong costs every screen that gets built on it; a layout that was guessed wrong costs a redraw. Probe the expensive unknown first, while the other end is still cheap to throw away.
Finding the blocker early is the whole point, because it is what lets you pivot. A blocker found in the first week is a decision, so you can reshape the flow, cut the scope, change the approach or move the work to somebody else. The same blocker found after the rest of the feature depends on it is a rewrite, and by then nobody can afford to change direction.
The takeaway Start where you are least confident. If you would be guessing at the screen, build a rough one on a real route. If you would be guessing at the rules, prove them with a query or a stub. Find the blocker while it is still a decision, pivot while the pivot is still cheap, and throw the probe away.
2. One idea per page & one call to action
A page exists so somebody can answer a question or make a decision. Give it two of those and one gets answered badly, because the design has no opinion about which matters. The test costs one sentence, and that sentence is a verb and one object.
One idea
- “Find an invoice.” A list, a search box, a row per invoice.
- “Pay this invoice.” One invoice, one amount, one button.
-
“Find an invoice, pay it, change the address and invite a colleague” is three screens in a trench coat. Every extra job on the page steals attention from the first.
One idea does not mean one element. A dashboard of twelve numbers still holds a single idea, which is the question is anything wrong right now?
One call to action
- Primary. Filled, and the only one on the screen, because it is the thing the page is for.
- Secondary. Outline or plain, for the exits that keep the work (“Save draft”, “Preview”).
- Tertiary. A link, for history and side roads.
- Destructive. Not beside the primary by accident. Behind a confirmation, an overflow menu, or its own panel that names the object, such as “Delete invoice INV-1042”.
- The label is a verb and an object. “Create account”, “Send invoice”. Never “Submit” or “OK”.
Example
Delete now lives in More actions, with the invoice number in its label and a confirmation before it does anything.
The call to action survives every state. It is the same element, saying something true about the moment.
- Empty. The action is the reason the page exists. “Create your first invoice”, not a blank table.
- Loading. Keeps the same label and disables the action, with a line saying what is happening. Not a button that silently does nothing.
- Error. The same primary action again, because the user's intent did not change. “Try again” sits beside it, not instead of it.
- Success. The action points at the next screen. A page whose job is done should hand off, not invite the user to do it twice.
The takeaway One screen, one decision. If two buttons look equally important, the user is being asked to make an interface decision that the design never made.
3. Component architecture
A component is a name for one job and a contract, props in, events out.
Angular's syntax draws that contract for you, with [props] coming in and
(events) going out. A component name should describe the job it performs. If you
cannot name that job in a sentence, the component is doing too much and should be split.
One feature, five levels. Each level is a component with a contract, and each one is built from the level below it. Every component is shown as its three parts in order, the HTML, the JavaScript and the CSS, because the contract only makes sense with all three in view. The code is Angular and abbreviated on purpose, so it shows the shape rather than the plumbing.
1 Primitive: status-pill
It takes one prop, status, and emits nothing. One word and one color, and no knowledge of invoices.
HTML
<!-- status-pill.component.html: the value that came in is the word and the color -->
<span class="pill" [attr.data-status]="status">{{ status | titlecase }}</span>
JavaScript
// status-pill.component.ts: one input, four allowed words, nothing else
import { Component, Input } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
@Component({
selector: 'status-pill',
standalone: true,
imports: [TitleCasePipe],
templateUrl: './status-pill.component.html',
styleUrls: ['./status-pill.component.css'],
})
export class StatusPillComponent {
@Input({ required: true }) status!: 'draft' | 'sent' | 'paid' | 'overdue';
}
CSS
/* status-pill.component.css: the shape once, then one color per status */
.pill {
display: inline-block;
padding: 2px 8px;
border: 1px solid currentColor;
border-radius: 999px;
font: 700 10.5px/1.6 ui-monospace, Consolas, monospace;
letter-spacing: .08em;
text-transform: uppercase;
}
.pill[data-status="draft"] { color: #6b5a45; }
.pill[data-status="sent"] { color: #1a6b64; }
.pill[data-status="paid"] { color: #2f7d3a; }
.pill[data-status="overdue"] { color: #bf3d24; }
2 Feature: invoice-row
It takes an invoice and emits open, with the number in its detail.
The pill is handed this row's status, and the row never formats it itself.
HTML
<!-- invoice-row.component.html: the pill gets the status from this invoice -->
<button type="button" class="invoice-row" (click)="open.emit(invoice.number)">
<span>{{ invoice.number }}</span>
<span>{{ invoice.customer }}</span>
<span>{{ invoice.amount | currency }}</span>
<status-pill [status]="invoice.status"></status-pill>
</button>
JavaScript
// invoice-row.component.ts: one invoice in, one number out, no formatting of its own
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { StatusPillComponent } from '../status-pill/status-pill.component';
import type { Invoice } from '../invoice.model';
@Component({
selector: 'invoice-row',
standalone: true,
imports: [CurrencyPipe, StatusPillComponent],
templateUrl: './invoice-row.component.html',
styleUrls: ['./invoice-row.component.css'],
})
export class InvoiceRowComponent {
@Input({ required: true }) invoice!: Invoice;
@Output() open = new EventEmitter<string>();
}
CSS
/* invoice-row.component.css: the inside of one row, and no outer margin */
.invoice-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 12px;
align-items: center;
width: 100%;
padding: 8px 10px;
color: inherit;
text-align: left;
background: none;
border: 0;
border-bottom: 1px solid var(--rule, #dcc9a8);
cursor: pointer;
}
.invoice-row:hover { background: var(--amber-tint, rgba(201, 122, 16, .12)); }
.invoice-row:focus-visible { outline: 3px solid var(--seal, #bf3d24); outline-offset: -3px; }
3 New feature on top of it: invoice-list
It takes a label and the invoices to show, and emits
selected, translated from each row's open.
HTML
<!-- invoice-list.component.html: one row per invoice, and the event passed up -->
<section>
<h3>{{ label }}</h3>
<invoice-row *ngFor="let invoice of invoices"
[invoice]="invoice"
(open)="selected.emit($event)"></invoice-row>
</section>
JavaScript
// invoice-list.component.ts: it arranges what it is handed and translates one event
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { NgFor } from '@angular/common';
import { InvoiceRowComponent } from '../invoice-row/invoice-row.component';
import type { Invoice } from '../invoice.model';
@Component({
selector: 'invoice-list',
standalone: true,
imports: [NgFor, InvoiceRowComponent],
templateUrl: './invoice-list.component.html',
styleUrls: ['./invoice-list.component.css'],
})
export class InvoiceListComponent {
@Input({ required: true }) label!: string;
@Input({ required: true }) invoices!: Invoice[];
@Output() selected = new EventEmitter<string>();
}
CSS
/* invoice-list.component.css: the space between rows belongs here, not to the row */
:host { display: block; }
h3 {
margin: 0 0 6px;
font: 700 11px/1.4 ui-monospace, Consolas, monospace;
letter-spacing: .1em;
text-transform: uppercase;
color: #6b5a45;
}
invoice-row { display: block; }
invoice-row + invoice-row { margin-top: 2px; }
4 Page: invoices-page
It takes state and emits nothing of its own. It reacts to a selected invoice by
changing the route, and it is the only level that knows an API exists. Nearly all of its code
is state and branches, which is why it has almost no CSS.
HTML
<!-- invoices-page.component.html: one branch per state --> <ng-container [ngSwitch]="state.status"> <p *ngSwitchCase="'loading'" role="status">Loading invoices…</p> <p *ngSwitchCase="'empty'">No invoices yet.</p> <p *ngSwitchCase="'error'" role="alert">We could not load invoices.</p> <invoice-list *ngSwitchCase="'ready'" label="Recent invoices" [invoices]="state.invoices" (selected)="open($event)"></invoice-list> </ng-container> <!-- open(number) is the only place that touches the router -->
JavaScript
// invoices-page.component.ts: it owns the state, the request and the route
import { Component, inject, OnInit } from '@angular/core';
import { NgSwitch, NgSwitchCase } from '@angular/common';
import { Router } from '@angular/router';
import { InvoiceListComponent } from '../invoice-list/invoice-list.component';
import { InvoicesApi } from '../invoices.api';
import type { Invoice } from '../invoice.model';
type PageState =
| { status: 'loading' }
| { status: 'ready'; invoices: Invoice[] }
| { status: 'empty' }
| { status: 'error' };
@Component({
selector: 'invoices-page',
standalone: true,
imports: [NgSwitch, NgSwitchCase, InvoiceListComponent],
templateUrl: './invoices-page.component.html',
styleUrls: ['./invoices-page.component.css'],
})
export class InvoicesPageComponent implements OnInit {
private api = inject(InvoicesApi);
private router = inject(Router);
state: PageState = { status: 'loading' };
async ngOnInit(): Promise<void> {
try {
const invoices = await this.api.list();
this.state = invoices.length
? { status: 'ready', invoices }
: { status: 'empty' };
} catch {
this.state = { status: 'error' };
}
}
open(number: string): void {
this.router.navigate(['/invoices', number]);
}
}
CSS
/* invoices-page.component.css: no design of its own, only the space the branches sit in */
:host { display: block; padding: 16px; }
p { margin: 0; font: 14px/1.5 Georgia, "Palatino Linotype", serif; color: #6b5a45; }
5 Shell: app-shell
It takes one prop, title, and emits nothing. It provides the skip link, the header,
the nav and the outlet, and it never knows which page it is holding.
HTML
<!-- app-shell.component.html: the frame every page sits in -->
<a class="skip-link" href="#main">Skip to content</a>
<header><h1>{{ title }}</h1></header>
<nav aria-label="Main">
<a routerLink="/invoices" routerLinkActive="active">Invoices</a>
<a routerLink="/customers">Customers</a>
</nav>
<main id="main">
<router-outlet></router-outlet>
</main>
JavaScript
// app-shell.component.ts: layout and navigation, and it never loads data
import { Component, Input } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-shell',
standalone: true,
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './app-shell.component.html',
styleUrls: ['./app-shell.component.css'],
})
export class AppShellComponent {
@Input() title = 'Gather';
}
CSS
/* app-shell.component.css: the frame, the skip link and the current-page mark */
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 8px;
top: 8px;
padding: 8px 12px;
background: #fffaf0;
border: 2px solid #2a2118;
border-radius: 8px;
}
header, nav, main { padding: 0 16px; }
nav { display: flex; gap: 10px; }
nav a.active { border-bottom: 3px solid #c97a10; }
The takeaway One job, one name, one contract. If you cannot say what a component is for in a sentence, it is not a component yet; it is a screen that happens to be in a file.
4. State management
State is the memory an application holds while it runs, such as the invoices it fetched, the filter a user typed, or the row they selected. Managing it means deciding who owns each piece, who may read it, and when it is thrown away. Left alone, that memory gets copied, goes stale, and outlives the screen that created it.
The five components in the panel above are also a memory map. The shell holds none of it. The page owns the data and the request that fetched it. The list arranges what it is given. The row and the pill hold nothing at all.
Where each piece of memory belongs
- The URL holds anything a user would expect to survive a refresh or a shared link, which is the search text, the filters, the sort, the page number and the selected tab.Example: /invoices?q=north&status=overdue&sort=amount&page=2
- The server cache holds the data itself, keyed by the request that produced it, with a rule for when it goes stale.Example: the rows from /api/invoices?status=overdue, and the time they arrived
- The component holds what dies with it, such as this menu being open or this row being selected.Example: menu open, row expanded, an unsaved draft in a field
- Derived values are not stored. A total, a count or a disabled flag is a calculation, not a second copy of the data; two pieces of state that must agree will eventually disagree.Example: total, count, canSubmit and isEmpty are all computed on read
- Lift state only as far as the nearest common ancestor that needs it, then stop. A prop threaded through three components that never read it is state living in the wrong place.Example: the selected invoice lives on the page that shows both list and detail; a sidebar toggle stays in the shell
What one owner per piece buys you
- One source of truth. The page holds the invoices and the list receives them. Nothing else keeps a copy, so two screens cannot show different answers to the same question.
- Leaves stay stateless. The row and the pill have nothing to reset. They render what they are handed, which is why they can move to another screen without a rewrite and be tested by passing one value.
- Memory dies with its owner. When the page is destroyed, its data, timers and open subscriptions go with it. Coming back to the screen starts clean instead of showing last week's rows.
- Shared memory lives in a service. A selection, a session or a filter that two features need is held once and injected, instead of being threaded through the shell and the list on the way down.
- Derived values are computed, not stored. A total, a count or a disabled flag is calculated from state at render time. A second copy of it is a second thing that can be wrong.
- The URL keeps what should survive. Search, sort, page number and the open record belong in the route, so a refresh, the back button and a shared link all land on the same screen.
When the memory goes away
- Unsubscribe with the component. A request, a timer or a subscription that outlives its screen keeps the old data and the old callback alive. Release it when the component is destroyed.
- Clear what belongs to a session on sign-out. A cache that survives a logout shows the next person the last person's rows.
- Drop what you can recompute. If a value can be calculated from state, storing it means remembering to update it.
- Do not copy server data into local state just to render it. The cache is already the copy; a second one drifts the moment the first is refreshed.
The server call walkthrough follows one value through
one request. A loading flag switches on when the request starts, is held while the
request is in flight, and is dropped when the response arrives. A screen does the same thing
with many values at once, which is why the rule in this panel applies at both sizes. Every value
has one owner and a clear moment when it is released.
The takeaway One owner per piece of memory, and a clear moment when it is released. If two places can change the same value, neither of them is the source of truth.
5. Applying CSS with AI
CSS is not one stylesheet. It is several layers of rules, and every layer applies to the same element. The browser reads them in a fixed order and the last matching rule wins, which is why a style you wrote can quietly lose to one you never looked at.
Two questions come out of that. Which rule wins when two of them disagree, and where a rule belongs so the winner stays obvious. The first is the cascade, the second is the whole job, and the first has to come first. When a model writes the CSS, it answers both on your behalf unless the prompt answers them first.
First, the layers
Before anything is painted, the browser collects every rule that matches an element and sorts
them. Origin comes first, so a browser default loses to your stylesheet and your stylesheet loses
to an inline style. Inside your own stylesheets, specificity decides, where .card .btn
beats .btn. When two selectors are equally specific, the rule read last wins, which
means file order.
Here is what that looks like when a global rule and a component rule want the same padding.
Then, where a rule belongs
Global CSS
Tokens
Named values for the design decisions that must be identical everywhere, so a color or a spacing step is written once and read by every screen.
Why: a token is the only place a value can change for everyone, so anything that has to agree across screens has to live here.
Example: --space-3: 12px in tokens.css, then padding: var(--space-3) in the componentReset and base
The starting point every element begins from before any component touches it, replacing the browser's own defaults with the ones the app has chosen.
Why: every component inherits from this layer, so a decision made here is one you never have to repeat inside a component.
Example: box-sizing: border-box on every element, and a body font every component inheritsLayout
The rules that decide where content sits on every page, so pages agree about width, gutters and rhythm instead of each inventing its own.
Why: it sets the space every component is placed inside, and no component can fix a page level layout from the inside.
Example: one page shell with a max width, side gutters and a single column stackA few utilities
Small single-purpose classes that do one thing wherever they are used, named for the effect rather than for a place.
Why: every screen shares them, so a vague name or a broad rule here is hard to change later without touching the whole app.
Example: .visually-hidden, used by any component that needs to hide text but keep it for a screen reader
A global selector should name a decision (--space-3), never a
thing (#sidebar .card).
Scoped CSS
Internals
The styling that only makes sense inside one component, such as the padding inside a card, the gap between a label and its field, or the hover of a row.
Why: nothing outside the component can see these rules, so changing them cannot break another screen.
Example: .card { padding: 12px; } in InvoiceCard.module.cssAnything that would be wrong elsewhere
A rule that only holds for this one component, which is the whole test. Would this rule be wrong on another screen?
Why: if the answer is yes it was never global, it was global with better luck, and the next screen to reuse it will pay for the guess.
Example: a two column split that only the invoice detail screen usesPlacement is the parent's job
The space between two components belongs to the component that arranges them, not to the children it places.
Why: a component carrying its own outer margin can only ever be placed one way, which is exactly what a reusable component cannot afford.
Example: .list { display: grid; gap: 8px; } on the list, instead of a margin on each rowWhat still crosses a scoped boundary
Custom properties inherit
A custom property set on an ancestor reaches every descendant, inside a scoped component or not.
Text styles inherit
Font, color, line height and direction pass down from the page whether the component asks for them or not.
Layout leaks outward
An absolutely positioned element, a stacking context or a fixed height can change where elements outside the component sit.
Global element selectors
A rule for a tag such as button or ul applies inside a scoped component too.
Only Shadow DOM is a real wall
It blocks outside rules completely, except custom properties, which pass through by design.
Escape hatches
:global(…) in CSS Modules and ::ng-deep in Angular let a rule out of the scope, so each one needs a comment.
Rule by rule
A color, size, radius or font
A global token, as a custom property.
What to tell the model: use the token that already exists, never a new hex value or pixel size inside a component.
Example: color: var(--ink) instead of color: #2a2118Page width, gutters, section rhythm
Global layout styles.
What to tell the model: change the layout layer, not the outer margins of each component.
Example: --page-max: 1080px in the layout layer, read by the shellHow a component looks inside
That component's scoped styles.
What to tell the model: put the rule in the component's own stylesheet, and add no global selector for it.
Example: .card { padding: var(--space-3); } in InvoiceCard.module.cssThe space between two components
The parent that places them.
What to tell the model: put the gap on the list that arranges the rows, not on the row.
Example: .list { display: grid; gap: var(--space-2); } on the list, no margin on the rowWhat every button looks like
One shared button component, not five stylesheets.
What to tell the model: reuse the shared button, and do not restyle buttons screen by screen.
Example: one shared app-button, used by every screenOne deliberate exception
:global(…) with a comment saying why.
What to tell the model: say why in a comment, and expect a question if there are more than a few.
Example: /* why: third party markup */ :global(.flatpickr-calendar) { … }The takeaway Layers decide who wins. Ownership decides who should. Read the cascade first, then place each rule once, in the layer that owns the decision. A rule that would be wrong on another page was never scoped, it was global with better luck.
6. Accessibility first
Accessibility is a constraint at design time, a task at build time, and a rewrite after release. The fix is the same in all three cases, but the cost depends on when it is implemented.
| When it is caught | What it is | What it costs |
|---|---|---|
| Design | choosing a control that already works with a keyboard and a screen reader, so nothing has to be added later | minutes, and the design gets simpler because there is less to build |
| Build | adding labels, focus order, error text and a missing state to a control that is already drawn | hours, and it fights the styling, because the control was not chosen for this |
| After release | refactoring finished code that is already public, which puts behavior users depend on at risk of new bugs | the clients' trust, and the time spent fixing a regression they can see |
Five questions to ask while the screen is still a sketch. None of them needs a tool, and each one can remove an option before it becomes code.
- 1. Can the whole job be done with a keyboard, in an order that tells the story? What it means: every action a mouse can take must also be reachable with Tab, Enter and Space, and focus should move in the order the task happens, not the order the markup happens to be written in. What can I do now: write the steps of the task in the order a user does them, and check that each step lands on a real link or button. Replace anything in the sketch that is a div with a click handler.
- 2. Does every control have a name that survives without its visible label? What it means: a field needs a name in the markup, not only in the placeholder, because the placeholder disappears as soon as someone types and it is not the label a screen reader reads out. What can I do now: give every field a real label, and write the name so it still makes sense when it is read on its own, without the rest of the form around it.
- 3. What does a screen reader hear for role, name and state? What it means: a screen reader announces what a control is, what it is called and what state it is in. A div with an onClick is announced as nothing at all, because it has no role. What can I do now: write the announcement into the sketch, such as “Button, pressed”. If you cannot write that sentence, the control needs replacing before anyone builds it.
- 4. Does it survive 200% zoom, a 320px window, and doubled line spacing? What it means: at those sizes the text has to stay readable and every control has to stay reachable, with nothing cut off and no sideways scrolling to reach normal content. What can I do now: sketch the smallest version first, at 320px with text at double height, and see what disappears. A layout that only works because the text is small fails here.
- 5. What is left when color and motion are gone? What it means: color and animation are the first things lost to low vision, color blindness or a motion sensitivity, so the screen has to carry its meaning without them. What can I do now: draw the screen in greyscale, replace any signal that is only a color with a word or an icon, and check that loading still reads as loading and an error still reads as an error.
Errors that people can act on
A real label, tied to the field
What it is: a visible name for the field input, written in the markup and connected to the input it belongs to. What to do: use<label for="email">, never a placeholder standing in for the label.
The message belongs to the field
What it is: the error text is attached to the control it describes, so a screen reader reads it when focus reaches that field. What to do: pointaria-describedby at the message, set aria-invalid="true", and move focus to the first field that needs fixing.
Say what to do
What it is: get specific with what the user needs to do to continue their work vs a generic "error" message. What to do: write “Use at least 12 characters” instead of “Invalid password”.Never color alone
What it is: red text or a red border carries no message for a color-blind reader or a greyscale print. What to do: add an icon or the word “Error” beside the color.Keep what was typed
What it is: a failed submit that clears the form makes the user type everything a second time. What to do: re-render the form with the values still in it, and add only the validation messages.Announce what changed by itself
What it is: a change that happens without a click is silent unless the page announces it. What to do: mark “Saved” withrole="status" and a failure with role="alert", and never ship a spinner with no text.
44×44 CSS pixels
What it is: the smallest size for anything a finger has to hit, with space between targets. What to do: size the targets in the layout, not in a later audit.Contrast
What it is: how easy it is to tell text apart from the color behind it. When the two are close in lightness, the text fades into the background, first for people with low vision, then for anyone reading a phone in bright sunlight. Text has to be clearly darker or lighter than its background, and a focus ring has to stand out from whatever it sits on. What to do: use the color tokens instead of picking colors by eye. The palette has already been checked, so a token pair passes without you measuring anything. If you have to add a color, put it through a contrast checker, which does the math for you.Reduced motion
What it is: animation can make some people ill, and the operating system can ask the page for less of it. What to do: drop every transition when the system asks for reduced motion. It is one rule in the stylesheet.The takeaway Accessibility first is not “add ARIA later”. It is a set of constraints that removes options. If a design cannot be made accessible without a rewrite, change the design now. It is cheaper, and it usually makes the screen better for everyone who was not in the room when it was designed.
Sources
- Keyboard access, names, roles, states and target size. Web Content Accessibility Guidelines (WCAG) 2.2, W3C Recommendation, 5 October 2023. The criteria behind panel 6 are 2.1.1 Keyboard, 2.4.3 Focus Order, 3.3.2 Labels or Instructions, 4.1.2 Name, Role, Value and 4.1.3 Status Messages.
- Contrast, zoom, reflow and text spacing. How to Meet WCAG, the W3C quick reference, for 1.4.1 Use of Color, 1.4.3 Contrast (Minimum), 1.4.4 Resize Text, 1.4.10 Reflow, 1.4.11 Non-text Contrast and 1.4.12 Text Spacing.
- Reduced motion. Animation from Interactions, WCAG 2.3.3, and the prefers-reduced-motion media query, MDN.
- 44 by 44 CSS pixels is a mobile convention as much as a rule. It is WCAG 2.5.5 Target Size, which is level AAA, and it matches Apple's 44pt. WCAG 2.2 level AA asks for 24 by 24 (2.5.8), and Android recommends 48dp.
- Which rule wins when two rules disagree. CSS Cascading and Inheritance Level 5, W3C, and the MDN guide to the cascade, which is where origin, specificity and order of appearance are defined.
- Why a scoped rule stays local. The
CSS Modules specification for the
hashed class names, and
Angular's component styling guide
for emulated encapsulation and
:host.