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
|
||||
* index.tsx
|
||||
*/
|
||||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// 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/mixins';
|
||||
|
||||
@use 'styles/tokens' as *;
|
||||
@use 'styles/fonts' as *;
|
||||
@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 {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,45 @@
|
|||
// _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)
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue