feat(canary): UI design layer — vector-minimalism tokens + primitives
Operator (Carter) redirected from infra Phase 15 to UI design layer; design ethos "vector-minimalism" (specimen-cataloging mind, off-white paper, deep ink, alarm-red accent, hairline rules, halftone/grain overlays). Foundation only — pages follow. Tokens: 7-step paper/ink/alarm/signal palette + paper-grain SVG noise + halftone dot data-uri + hairline width. Stripped color palette in white-canvas commit is deliberately NOT re-introduced; this is a project-owned palette, not the template's. Primitives (each in own folder with module SCSS): - Strip / StripItem — archive header (vol/issue/serial pills) - LeaderLabel — index + rule + caption annotation - SpecimenCard — tag + body + serial container (paper|ink|alarm tone) - DataRow — field=value with dotted leader (mono|emphasize|alarm) - Pill — paper|ink|alarm|signal label - CopyField — labelled mono code with clipboard button - Halftone — repeating dot strip (sparse|normal|dense) - SerialBar — fingerprint bar + code two-row mono display - Glyph — 7 token-type SVG icons (currentColor, 24×24) - Field (Text/Textarea/Select/Wrap) — labelled form inputs with leader rule - Button — primary|ghost|alarm × sm|md|lg Gates green: pnpm typecheck + pnpm biome check + pnpm lint:scss + pnpm build (294ms). No backend changes. No page-level routing changes yet.
This commit is contained in:
parent
1d205c1791
commit
f5d893ab8c
|
|
@ -0,0 +1,89 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Button.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: $hairline solid $ink;
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
font-family: $font-mono;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color $duration-fast $ease-out,
|
||||||
|
color $duration-fast $ease-out;
|
||||||
|
|
||||||
|
&[data-size='sm'] {
|
||||||
|
padding-block: $space-1-5;
|
||||||
|
padding-inline: $space-3;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='md'] {
|
||||||
|
padding-block: $space-2-5;
|
||||||
|
padding-inline: $space-5;
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='lg'] {
|
||||||
|
padding-block: $space-3-5;
|
||||||
|
padding-inline: $space-7;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-full='true'] {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $paper;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost'] {
|
||||||
|
background-color: transparent;
|
||||||
|
color: $ink;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='alarm'] {
|
||||||
|
background-color: $paper;
|
||||||
|
color: $alarm-deep;
|
||||||
|
border-color: $alarm;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $alarm;
|
||||||
|
color: $paper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-busy='true'] {
|
||||||
|
cursor: progress;
|
||||||
|
|
||||||
|
.label::after {
|
||||||
|
content: ' …';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
import styles from './Button.module.scss'
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'ghost' | 'alarm'
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
type ButtonProps = PropsWithChildren<
|
||||||
|
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> & {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
size?: ButtonSize
|
||||||
|
fullWidth?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(props, ref) {
|
||||||
|
const {
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
busy = false,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
className={styles.button}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-full={fullWidth}
|
||||||
|
data-busy={busy}
|
||||||
|
>
|
||||||
|
<span className={styles.label}>{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// CopyField.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: stretch;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
background-color: $paper;
|
||||||
|
|
||||||
|
&[data-full='true'] {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: $space-3;
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: $space-2;
|
||||||
|
padding-inline: $space-3;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
letter-spacing: $tracking-wide;
|
||||||
|
color: $ink;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
padding-block: $space-2;
|
||||||
|
padding-inline: $space-3;
|
||||||
|
border-inline-start: $hairline solid $rule;
|
||||||
|
background-color: $paper-deep;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color $duration-fast $ease-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import styles from './CopyField.module.scss'
|
||||||
|
|
||||||
|
type CopyFieldProps = {
|
||||||
|
value: string
|
||||||
|
label?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const COPY_FEEDBACK_MS = 1200
|
||||||
|
|
||||||
|
export function CopyField({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
fullWidth = false,
|
||||||
|
}: CopyFieldProps): React.ReactElement {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
async function handleCopy(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), COPY_FEEDBACK_MS)
|
||||||
|
} catch (_err) {
|
||||||
|
toast.error('Copy failed — your browser blocked clipboard access')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.field} data-full={fullWidth}>
|
||||||
|
{label ? <span className={styles.label}>{label}</span> : null}
|
||||||
|
<code className={styles.value}>{value}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.copy}
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={`Copy ${label ?? 'value'}`}
|
||||||
|
>
|
||||||
|
{copied ? 'copied' : 'copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// DataRow.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $space-2;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
line-height: $line-height-snug;
|
||||||
|
|
||||||
|
&[data-alarm='true'] {
|
||||||
|
color: $alarm-deep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-mute;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader {
|
||||||
|
align-self: end;
|
||||||
|
block-size: $hairline;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
$rule-soft 0,
|
||||||
|
$rule-soft 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
background-size: 8px $hairline;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
min-inline-size: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: inherit;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
&[data-mono='true'] {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
letter-spacing: $tracking-wide;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-emphasize='true'] {
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react'
|
||||||
|
import styles from './DataRow.module.scss'
|
||||||
|
|
||||||
|
type DataRowProps = PropsWithChildren<{
|
||||||
|
label: ReactNode
|
||||||
|
mono?: boolean
|
||||||
|
emphasize?: boolean
|
||||||
|
alarm?: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function DataRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
mono = false,
|
||||||
|
emphasize = false,
|
||||||
|
alarm = false,
|
||||||
|
}: DataRowProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.row} data-alarm={alarm}>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
<span className={styles.leader} aria-hidden="true" />
|
||||||
|
<span className={styles.value} data-mono={mono} data-emphasize={emphasize}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Field.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-mute;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: $alarm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
block-size: $hairline;
|
||||||
|
background-color: $rule-hair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding-block: $space-2-5;
|
||||||
|
padding-inline: $space-3;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
background-color: $paper;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
color: $ink;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: border-color $duration-fast $ease-out;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $ink-faint;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $ink;
|
||||||
|
box-shadow: inset 0 0 0 $hairline $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field[data-invalid='true'] & {
|
||||||
|
border-color: $alarm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-block-size: 5lh;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: $font-sans;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding-inline-end: $space-8;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, $ink 50%),
|
||||||
|
linear-gradient(135deg, $ink 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $ink-mute;
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $alarm;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
InputHTMLAttributes,
|
||||||
|
PropsWithChildren,
|
||||||
|
ReactNode,
|
||||||
|
SelectHTMLAttributes,
|
||||||
|
TextareaHTMLAttributes,
|
||||||
|
} from 'react'
|
||||||
|
import { forwardRef, useId } from 'react'
|
||||||
|
import styles from './Field.module.scss'
|
||||||
|
|
||||||
|
type FieldWrapProps = PropsWithChildren<{
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
index?: string
|
||||||
|
required?: boolean
|
||||||
|
htmlFor?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function FieldWrap({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
index,
|
||||||
|
required,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
}: FieldWrapProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.field} data-invalid={error ? 'true' : 'false'}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
{index ? <span className={styles.index}>{index}</span> : null}
|
||||||
|
<label className={styles.label} htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
{required ? <span className={styles.required}> *</span> : null}
|
||||||
|
</label>
|
||||||
|
<span className={styles.rule} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{hint && !error ? <p className={styles.hint}>{hint}</p> : null}
|
||||||
|
{error ? (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextFieldProps = Omit<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'className' | 'id'
|
||||||
|
> & {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
index?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||||
|
function TextField(props, ref) {
|
||||||
|
const { label, hint, error, index, required, ...rest } = props
|
||||||
|
const fallbackId = useId()
|
||||||
|
const id = rest.name ? `f-${rest.name}` : fallbackId
|
||||||
|
return (
|
||||||
|
<FieldWrap
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
error={error}
|
||||||
|
index={index}
|
||||||
|
required={required}
|
||||||
|
htmlFor={id}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
id={id}
|
||||||
|
required={required}
|
||||||
|
ref={ref}
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</FieldWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextareaFieldProps = Omit<
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
'className' | 'id'
|
||||||
|
> & {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
index?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextareaField = forwardRef<HTMLTextAreaElement, TextareaFieldProps>(
|
||||||
|
function TextareaField(props, ref) {
|
||||||
|
const { label, hint, error, index, required, ...rest } = props
|
||||||
|
const fallbackId = useId()
|
||||||
|
const id = rest.name ? `f-${rest.name}` : fallbackId
|
||||||
|
return (
|
||||||
|
<FieldWrap
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
error={error}
|
||||||
|
index={index}
|
||||||
|
required={required}
|
||||||
|
htmlFor={id}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
{...rest}
|
||||||
|
id={id}
|
||||||
|
required={required}
|
||||||
|
ref={ref}
|
||||||
|
className={styles.textarea}
|
||||||
|
/>
|
||||||
|
</FieldWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type SelectFieldProps = Omit<
|
||||||
|
SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
|
'className' | 'id'
|
||||||
|
> & {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
error?: ReactNode
|
||||||
|
index?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
|
||||||
|
function SelectField(props, ref) {
|
||||||
|
const { label, hint, error, index, required, children, ...rest } = props
|
||||||
|
const fallbackId = useId()
|
||||||
|
const id = rest.name ? `f-${rest.name}` : fallbackId
|
||||||
|
return (
|
||||||
|
<FieldWrap
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
error={error}
|
||||||
|
index={index}
|
||||||
|
required={required}
|
||||||
|
htmlFor={id}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
{...rest}
|
||||||
|
id={id}
|
||||||
|
required={required}
|
||||||
|
ref={ref}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</FieldWrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Glyph.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
display: block;
|
||||||
|
color: $ink;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { TokenType } from '@/api'
|
||||||
|
import styles from './Glyph.module.scss'
|
||||||
|
|
||||||
|
type GlyphProps = {
|
||||||
|
type: TokenType
|
||||||
|
size?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Glyph({
|
||||||
|
type,
|
||||||
|
size = 28,
|
||||||
|
title,
|
||||||
|
}: GlyphProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={styles.glyph}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
role={title ? 'img' : 'presentation'}
|
||||||
|
aria-label={title}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="square"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
>
|
||||||
|
{renderGlyph(type)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGlyph(type: TokenType): React.ReactElement {
|
||||||
|
switch (type) {
|
||||||
|
case 'webbug':
|
||||||
|
return <WebbugGlyph />
|
||||||
|
case 'slowredirect':
|
||||||
|
return <SlowRedirectGlyph />
|
||||||
|
case 'docx':
|
||||||
|
return <DocxGlyph />
|
||||||
|
case 'pdf':
|
||||||
|
return <PdfGlyph />
|
||||||
|
case 'kubeconfig':
|
||||||
|
return <KubeconfigGlyph />
|
||||||
|
case 'envfile':
|
||||||
|
return <EnvfileGlyph />
|
||||||
|
case 'mysql':
|
||||||
|
return <MysqlGlyph />
|
||||||
|
default:
|
||||||
|
return <WebbugGlyph />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebbugGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="4" height="4" fill="currentColor" stroke="none" />
|
||||||
|
<rect x="9" y="3" width="4" height="4" />
|
||||||
|
<rect x="15" y="3" width="4" height="4" />
|
||||||
|
<rect x="3" y="9" width="4" height="4" />
|
||||||
|
<rect x="9" y="9" width="4" height="4" />
|
||||||
|
<rect x="15" y="9" width="4" height="4" />
|
||||||
|
<rect x="3" y="15" width="4" height="4" />
|
||||||
|
<rect x="9" y="15" width="4" height="4" />
|
||||||
|
<rect x="15" y="15" width="4" height="4" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlowRedirectGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="3.5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="6.5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="9.5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<line x1="12" y1="12" x2="20" y2="12" />
|
||||||
|
<polyline points="17,8 21,12 17,16" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocxGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M5 3 L15 3 L19 7 L19 21 L5 21 Z" />
|
||||||
|
<line x1="15" y1="3" x2="15" y2="7" />
|
||||||
|
<line x1="15" y1="7" x2="19" y2="7" />
|
||||||
|
<line x1="8" y1="11" x2="16" y2="11" />
|
||||||
|
<line x1="8" y1="14" x2="16" y2="14" />
|
||||||
|
<line x1="8" y1="17" x2="13" y2="17" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M5 3 L15 3 L19 7 L19 21 L5 21 Z" />
|
||||||
|
<line x1="15" y1="3" x2="15" y2="7" />
|
||||||
|
<line x1="15" y1="7" x2="19" y2="7" />
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="17"
|
||||||
|
fontFamily="ui-monospace, monospace"
|
||||||
|
fontSize="6"
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KubeconfigGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<polygon points="12,3 19.5,7.5 19.5,16.5 12,21 4.5,16.5 4.5,7.5" />
|
||||||
|
<circle cx="12" cy="12" r="2.5" fill="currentColor" stroke="none" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="9.5" />
|
||||||
|
<line x1="19.5" y1="7.5" x2="14.2" y2="11" />
|
||||||
|
<line x1="19.5" y1="16.5" x2="14.2" y2="13" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="14.5" />
|
||||||
|
<line x1="4.5" y1="16.5" x2="9.8" y2="13" />
|
||||||
|
<line x1="4.5" y1="7.5" x2="9.8" y2="11" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnvfileGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M8 4 C5 4 5 8 5 12 C5 16 5 20 8 20" />
|
||||||
|
<path d="M16 4 C19 4 19 8 19 12 C19 16 19 20 16 20" />
|
||||||
|
<line x1="8" y1="11.5" x2="16" y2="11.5" />
|
||||||
|
<line x1="8" y1="13.5" x2="16" y2="13.5" />
|
||||||
|
<circle cx="12" cy="8.5" r="0.7" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="12" cy="16.5" r="0.7" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MysqlGlyph(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ellipse cx="12" cy="5.5" rx="7" ry="2.5" />
|
||||||
|
<path d="M5 5.5 L5 11 C5 12.4 8.1 13.5 12 13.5 C15.9 13.5 19 12.4 19 11 L19 5.5" />
|
||||||
|
<path d="M5 11 L5 16.5 C5 17.9 8.1 19 12 19 C15.9 19 19 17.9 19 16.5 L19 11" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Halftone.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
|
||||||
|
.halftone {
|
||||||
|
inline-size: 100%;
|
||||||
|
background-image: $halftone-dots;
|
||||||
|
background-repeat: repeat;
|
||||||
|
|
||||||
|
&[data-density='sparse'] {
|
||||||
|
background-size: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-density='normal'] {
|
||||||
|
background-size: 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-density='dense'] {
|
||||||
|
background-size: 5px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import styles from './Halftone.module.scss'
|
||||||
|
|
||||||
|
type HalftoneProps = {
|
||||||
|
density?: 'sparse' | 'normal' | 'dense'
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Halftone({
|
||||||
|
density = 'normal',
|
||||||
|
height = 24,
|
||||||
|
}: HalftoneProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.halftone}
|
||||||
|
data-density={density}
|
||||||
|
style={{ blockSize: `${height}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// LeaderLabel.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
max-width: 22ch;
|
||||||
|
font-family: $font-mono;
|
||||||
|
|
||||||
|
&[data-align='right'] {
|
||||||
|
text-align: right;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-mute;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
block-size: $hairline;
|
||||||
|
background-color: $rule;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
letter-spacing: $tracking-wide;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
line-height: $line-height-snug;
|
||||||
|
color: $ink-mute;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: $tracking-normal;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react'
|
||||||
|
import styles from './LeaderLabel.module.scss'
|
||||||
|
|
||||||
|
type LeaderLabelProps = PropsWithChildren<{
|
||||||
|
index?: string
|
||||||
|
caption?: ReactNode
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function LeaderLabel({
|
||||||
|
children,
|
||||||
|
index,
|
||||||
|
caption,
|
||||||
|
align = 'left',
|
||||||
|
}: LeaderLabelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.label} data-align={align}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
{index ? <span className={styles.index}>{index}</span> : null}
|
||||||
|
<span className={styles.rule} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>{children}</div>
|
||||||
|
{caption ? <div className={styles.caption}>{caption}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Pill.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding-block: $space-0-5;
|
||||||
|
padding-inline: $space-2;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
font-family: $font-mono;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&[data-size='sm'] {
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='md'] {
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
padding-block: $space-1;
|
||||||
|
padding-inline: $space-2-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-tone='paper'] {
|
||||||
|
background-color: $paper;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-tone='ink'] {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
border-color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-tone='alarm'] {
|
||||||
|
background-color: $alarm-tint;
|
||||||
|
color: $alarm-deep;
|
||||||
|
border-color: $alarm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-tone='signal'] {
|
||||||
|
background-color: $signal-tint;
|
||||||
|
color: $signal;
|
||||||
|
border-color: $signal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
import styles from './Pill.module.scss'
|
||||||
|
|
||||||
|
type PillProps = PropsWithChildren<{
|
||||||
|
tone?: 'paper' | 'ink' | 'alarm' | 'signal'
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function Pill({
|
||||||
|
children,
|
||||||
|
tone = 'paper',
|
||||||
|
size = 'sm',
|
||||||
|
}: PillProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className={styles.pill} data-tone={tone} data-size={size}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// SerialBar.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.serial {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 0 $space-2;
|
||||||
|
align-items: baseline;
|
||||||
|
font-family: $font-mono;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
grid-row: 2;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wide;
|
||||||
|
color: $ink-soft;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import styles from './SerialBar.module.scss'
|
||||||
|
|
||||||
|
type SerialBarProps = {
|
||||||
|
value: string
|
||||||
|
prefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_CHARS = ['▮', '▯', '▮', '▮', '▯', '▮', '▯', '▯', '▮']
|
||||||
|
|
||||||
|
function fingerprintToBars(value: string): string {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return BAR_CHARS.join('')
|
||||||
|
}
|
||||||
|
let acc = ''
|
||||||
|
for (let i = 0; i < 22; i += 1) {
|
||||||
|
const ch = value.charCodeAt(i % value.length)
|
||||||
|
acc += (ch & 1) === 0 ? '▮' : '▯'
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SerialBar({
|
||||||
|
value,
|
||||||
|
prefix = 'SN',
|
||||||
|
}: SerialBarProps): React.ReactElement {
|
||||||
|
const bars = useMemo(() => fingerprintToBars(value), [value])
|
||||||
|
return (
|
||||||
|
<div className={styles.serial}>
|
||||||
|
<span className={styles.prefix}>{prefix}</span>
|
||||||
|
<span className={styles.bars} aria-hidden="true">
|
||||||
|
{bars}
|
||||||
|
</span>
|
||||||
|
<span className={styles.code}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// SpecimenCard.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
padding: $space-8 $space-6 $space-6;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
background-color: $paper;
|
||||||
|
|
||||||
|
&[data-tone='ink'] {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
border-color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-tone='alarm'] {
|
||||||
|
border-color: $alarm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-dense='true'] {
|
||||||
|
padding: $space-4 $space-4 $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint-up('md') {
|
||||||
|
padding-inline: $space-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: calc(-1 * #{$space-3});
|
||||||
|
inset-inline-start: $space-4;
|
||||||
|
padding-block: $space-1;
|
||||||
|
padding-inline: $space-2;
|
||||||
|
background-color: $paper;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.card[data-tone='ink'] & {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
border-color: $paper-edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card[data-tone='alarm'] & {
|
||||||
|
background-color: $paper;
|
||||||
|
border-color: $alarm;
|
||||||
|
color: $alarm-deep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.serial {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: calc(-1 * #{$space-3});
|
||||||
|
inset-inline-end: $space-4;
|
||||||
|
padding-block: $space-1;
|
||||||
|
padding-inline: $space-2;
|
||||||
|
background-color: $paper;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
color: $ink-soft;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.card[data-tone='ink'] & {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper-edge;
|
||||||
|
border-color: $paper-edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHead {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-mute;
|
||||||
|
padding-block-end: $space-1;
|
||||||
|
border-block-end: $hairline solid $rule-hair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1-5;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react'
|
||||||
|
import styles from './SpecimenCard.module.scss'
|
||||||
|
|
||||||
|
type SpecimenCardProps = PropsWithChildren<{
|
||||||
|
tag?: ReactNode
|
||||||
|
serial?: ReactNode
|
||||||
|
tone?: 'paper' | 'ink' | 'alarm'
|
||||||
|
dense?: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function SpecimenCard({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
serial,
|
||||||
|
tone = 'paper',
|
||||||
|
dense = false,
|
||||||
|
}: SpecimenCardProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<article className={styles.card} data-tone={tone} data-dense={dense}>
|
||||||
|
{tag ? <span className={styles.tag}>{tag}</span> : null}
|
||||||
|
<div className={styles.body}>{children}</div>
|
||||||
|
{serial ? <span className={styles.serial}>{serial}</span> : null}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecimenCardSection({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: PropsWithChildren<{ label?: ReactNode }>): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
{label ? <header className={styles.sectionHead}>{label}</header> : null}
|
||||||
|
<div className={styles.sectionBody}>{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// Strip.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '../../styles/tokens' as *;
|
||||||
|
@use '../../styles/fonts' as *;
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-block: $space-2;
|
||||||
|
padding-inline: $space-4;
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: $font-size-3xs;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $ink-soft;
|
||||||
|
|
||||||
|
&[data-border='bottom'] {
|
||||||
|
border-bottom: $hairline solid $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-border='top'] {
|
||||||
|
border-top: $hairline solid $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-border='both'] {
|
||||||
|
border-block: $hairline solid $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-align='split'] {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-align='left'] {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $space-1-5;
|
||||||
|
padding-block: $space-0-5;
|
||||||
|
padding-inline: $space-2;
|
||||||
|
border: $hairline solid $rule;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&[data-inverted='true'] {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
border-color: $ink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $ink-mute;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '\00a0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// index.tsx
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react'
|
||||||
|
import styles from './Strip.module.scss'
|
||||||
|
|
||||||
|
type StripProps = PropsWithChildren<{
|
||||||
|
align?: 'left' | 'split'
|
||||||
|
border?: 'bottom' | 'top' | 'both' | 'none'
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function Strip({
|
||||||
|
children,
|
||||||
|
align = 'split',
|
||||||
|
border = 'bottom',
|
||||||
|
}: StripProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.strip} data-align={align} data-border={border}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StripItemProps = PropsWithChildren<{
|
||||||
|
inverted?: boolean
|
||||||
|
label?: string
|
||||||
|
mark?: ReactNode
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function StripItem({
|
||||||
|
children,
|
||||||
|
inverted = false,
|
||||||
|
label,
|
||||||
|
mark,
|
||||||
|
}: StripItemProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className={styles.item} data-inverted={inverted}>
|
||||||
|
{mark ? (
|
||||||
|
<span className={styles.mark} aria-hidden="true">
|
||||||
|
{mark}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{label ? <span className={styles.label}>{label}</span> : null}
|
||||||
|
<span className={styles.value}>{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,21 @@
|
||||||
/**
|
// ===================
|
||||||
* ©AngelaMos | 2026
|
// ©AngelaMos | 2026
|
||||||
* index.tsx
|
// index.tsx
|
||||||
*/
|
// ===================
|
||||||
|
|
||||||
|
export { Button } from './Button'
|
||||||
|
export { CopyField } from './CopyField'
|
||||||
|
export { DataRow } from './DataRow'
|
||||||
|
export {
|
||||||
|
FieldWrap,
|
||||||
|
SelectField,
|
||||||
|
TextareaField,
|
||||||
|
TextField,
|
||||||
|
} from './Field'
|
||||||
|
export { Glyph } from './Glyph'
|
||||||
|
export { Halftone } from './Halftone'
|
||||||
|
export { LeaderLabel } from './LeaderLabel'
|
||||||
|
export { Pill } from './Pill'
|
||||||
|
export { SerialBar } from './SerialBar'
|
||||||
|
export { SpecimenCard, SpecimenCardSection } from './SpecimenCard'
|
||||||
|
export { Strip, StripItem } from './Strip'
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,35 @@
|
||||||
@forward 'styles/fonts';
|
@forward 'styles/fonts';
|
||||||
@forward 'styles/mixins';
|
@forward 'styles/mixins';
|
||||||
|
|
||||||
|
@use 'styles/tokens' as *;
|
||||||
|
@use 'styles/fonts' as *;
|
||||||
@use 'styles/reset';
|
@use 'styles/reset';
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background-color: $paper;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
letter-spacing: $tracking-normal;
|
||||||
|
background-image: $paper-grain;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: 240px 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: $ink;
|
||||||
|
color: $paper;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,45 @@
|
||||||
// _tokens.scss
|
// _tokens.scss
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COLOR — specimen palette
|
||||||
|
// ============================================================================
|
||||||
|
$paper: #f1ede4;
|
||||||
|
$paper-deep: #ebe5d6;
|
||||||
|
$paper-edge: #d9d1bd;
|
||||||
|
|
||||||
|
$ink: #14110b;
|
||||||
|
$ink-soft: #46402f;
|
||||||
|
$ink-mute: #837a64;
|
||||||
|
$ink-faint: #b8ad95;
|
||||||
|
|
||||||
|
$rule: #1f1b13;
|
||||||
|
$rule-soft: #a39879;
|
||||||
|
$rule-hair: #d6cdb6;
|
||||||
|
|
||||||
|
$alarm: #c4341c;
|
||||||
|
$alarm-deep: #8c2210;
|
||||||
|
$alarm-tint: #f3d8d0;
|
||||||
|
|
||||||
|
$signal: #2b6b3a;
|
||||||
|
$signal-tint: #d8e5d6;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAPER GRAIN (svg noise, data-uri — overlay at low opacity)
|
||||||
|
// ============================================================================
|
||||||
|
$paper-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='7'/><feColorMatrix values='0 0 0 0 0.08 0 0 0 0 0.07 0 0 0 0 0.05 0 0 0 0.35 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HALFTONE DOT FIELD (svg, repeats — for separators / tonal patches)
|
||||||
|
// ============================================================================
|
||||||
|
$halftone-dots: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><circle cx='1' cy='1' r='1' fill='%2314110b'/></svg>");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HAIRLINE (the thin printed rule)
|
||||||
|
// ============================================================================
|
||||||
|
$hairline: 1px;
|
||||||
|
$hairline-bold: 2px;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SPACING (8px base system)
|
// SPACING (8px base system)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue