Add shared Icon primitive and controlled glyph vocabulary (Carbon, filled)

This commit is contained in:
2026-06-06 23:51:47 +03:00
parent af9ee1e4c0
commit 3e89d9a531
18 changed files with 587 additions and 77 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* Icon — the shared icon primitive and controlled vocabulary (arch 09 §5).
*
* Astrolabe is label-first: an icon is added only when it does real work, usually
* alongside text (arch 09 §5.1). This module is the single source of truth for the
* icon set — one glyph per meaning, app-wide (§5.2). Adding an icon means adding a
* `IconName` + a registry entry here, never inlining an SVG in a component.
*
* The glyphs are traced from IBM Carbon's icon set (32×32 grid, fill-based — they
* read as outlines but are filled shapes). Carbon is inspiration, not a dependency.
* Every glyph draws with `fill: currentColor`, so it inherits its text colour and
* themes for free.
*
* Icons are decorative by default (`aria-hidden`): the control around them carries
* the accessible name (text label, or an `aria-label` on an icon-only button — APG
* button pattern). Never rely on an icon alone to name a control.
*/
import type { ReactNode } from 'react';
import styles from './Icon.module.css';
/** The controlled icon vocabulary (arch 09 §5.2). One entry = one meaning. */
export type IconName =
| 'close' // close / dismiss (universal, icon-only)
| 'moon' // theme: switch to dark (universal, icon-only)
| 'sun' // theme: switch to light (universal, icon-only)
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
// triangle shape-codes warning apart from the round error/success/info.
| 'status-error' // Carbon ErrorFilled
| 'status-warning' // Carbon WarningAltFilled (triangle)
| 'status-success' // Carbon CheckmarkFilled
| 'status-info'; // Carbon InformationFilled
/** Carbon icon scale (arch 09 §5.3). 16px (sm) is the default, paired to 14px body. */
export type IconSize = 'sm' | 'md' | 'lg' | 'xl';
/** Inner SVG geometry per glyph, on Carbon's 0 0 32 32 grid. fill comes from CSS. */
const GLYPHS: Record<IconName, ReactNode> = {
close: (
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
delete: (
<>
<rect x="12" y="12" width="2" height="12" />
<rect x="18" y="12" width="2" height="12" />
<path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z" />
<rect x="12" y="2" width="8" height="2" />
</>
),
dataset: (
<>
<rect x="8" y="18" width="4" height="2" />
<rect x="14" y="18" width="4" height="2" />
<rect x="8" y="14" width="4" height="2" />
<rect x="14" y="22" width="4" height="2" />
<rect x="20" y="14" width="4" height="2" />
<rect x="20" y="22" width="4" height="2" />
<path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
</>
),
moon: (
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
),
sun: (
<>
<rect x="15" y="2" width="2" height="5" />
<rect
x="21.6675"
y="6.8536"
width="4.958"
height="1.9998"
transform="translate(1.5191 19.3744) rotate(-45)"
/>
<rect x="25" y="15" width="5" height="2" />
<rect
x="23.1466"
y="21.6675"
width="1.9998"
height="4.958"
transform="translate(-10.0018 24.1465) rotate(-45)"
/>
<rect x="15" y="25" width="2" height="5" />
<rect
x="5.3745"
y="23.1466"
width="4.958"
height="1.9998"
transform="translate(-14.7739 12.6256) rotate(-45)"
/>
<rect x="2" y="15" width="5" height="2" />
<rect
x="6.8536"
y="5.3745"
width="1.9998"
height="4.958"
transform="translate(-3.253 7.8535) rotate(-45)"
/>
<path d="M16,12a4,4,0,1,1-4,4,4.0045,4.0045,0,0,1,4-4m0-2a6,6,0,1,0,6,6,6,6,0,0,0-6-6Z" />
</>
),
// Filled status glyphs: the symbol is a winding-rule knockout in the solid shape,
// so it shows the surface colour through (Carbon's filled notification family).
'status-error': (
<path d="M16,2A13.914,13.914,0,0,0,2,16,13.914,13.914,0,0,0,16,30,13.914,13.914,0,0,0,30,16,13.914,13.914,0,0,0,16,2Zm5.4449,21L9,10.5557,10.5557,9,23,21.4448Z" />
),
'status-warning': (
<path d="M16.002,6.1714h-.004L4.6487,27.9966,4.6506,28H27.3494l.0019-.0034ZM14.875,12h2.25v9h-2.25ZM16,26a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,16,26Z" />
),
'status-success': (
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM14,21.5908l-5-5L10.5906,15,14,18.4092,21.41,11l1.5957,1.5859Z" />
),
'status-info': (
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,6a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,16,8Zm4,16.125H12v-2.25h2.875v-5.75H13v-2.25h4.125v8H20Z" />
),
};
export interface IconProps {
name: IconName;
/** Carbon scale; defaults to `sm` (16px) — the body-text default. */
size?: IconSize;
/** Extra class for layout-level overrides (margins, colour). */
className?: string;
}
export function Icon({ name, size = 'sm', className }: IconProps) {
return (
<svg
className={`${styles.icon} ${styles[size]}${className ? ` ${className}` : ''}`}
viewBox="0 0 32 32"
// Decorative: the enclosing control names itself (arch 09 §5.1).
aria-hidden="true"
focusable={false}
>
{GLYPHS[name]}
</svg>
);
}