diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss new file mode 100644 index 00000000..bc6d2ba9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss @@ -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; +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx new file mode 100644 index 00000000..8af6e6d7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx @@ -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, 'className'> & { + variant?: ButtonVariant + size?: ButtonSize + fullWidth?: boolean + busy?: boolean + } +> + +export const Button = forwardRef( + function Button(props, ref) { + const { + variant = 'primary', + size = 'md', + fullWidth = false, + busy = false, + type = 'button', + children, + disabled, + ...rest + } = props + return ( + + ) + } +) diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss new file mode 100644 index 00000000..b8655cb7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss @@ -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; + } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx new file mode 100644 index 00000000..10369444 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx @@ -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 { + 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 ( +
+ {label ? {label} : null} + {value} + +
+ ) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss new file mode 100644 index 00000000..b839dc30 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss @@ -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; + } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx new file mode 100644 index 00000000..67bbe503 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx @@ -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 ( +
+ {label} +
+ ) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss new file mode 100644 index 00000000..4a16cb5a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss @@ -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; +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx new file mode 100644 index 00000000..54a265de --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx @@ -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 ( +
+
+ {index ? {index} : null} + +
+ {children} + {hint && !error ?

{hint}

: null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +type TextFieldProps = Omit< + InputHTMLAttributes, + 'className' | 'id' +> & { + label: ReactNode + hint?: ReactNode + error?: ReactNode + index?: string +} + +export const TextField = forwardRef( + function TextField(props, ref) { + const { label, hint, error, index, required, ...rest } = props + const fallbackId = useId() + const id = rest.name ? `f-${rest.name}` : fallbackId + return ( + + + + ) + } +) + +type TextareaFieldProps = Omit< + TextareaHTMLAttributes, + 'className' | 'id' +> & { + label: ReactNode + hint?: ReactNode + error?: ReactNode + index?: string +} + +export const TextareaField = forwardRef( + function TextareaField(props, ref) { + const { label, hint, error, index, required, ...rest } = props + const fallbackId = useId() + const id = rest.name ? `f-${rest.name}` : fallbackId + return ( + +