feat(canary): manage dossier page + /m/:manageId route + 404 fallback

Manage route renders the per-token dossier — the "specimen catalog entry"
twin of the intake page. Same vector-minimalism mind: archive strip,
big-blocky headline, hairline-rule cards, mono labels, alarm-red only
when the trap is alive (eventsTotal > 0) or disabled.

Page composition:
- Top Strip (FIELD STATION / SECTION / SPECIES pill)
- Hero block — headline state-shifts: QUIET (no events, ink), TRAP TRIPPED
  (alarm red), TRAP MUTED (ink-mute). Memo rendered as printer-block quote.
- Halftone separator
- SpecimenCard with armed status / trigger count / silenced (15m window) /
  last seen / created / alert channel / filename — plus Trigger URL CopyField
- EventLogSection — cursor-paginated list of EventRow cards (timestamp +
  source_ip + geo + ASN + UA + referer + notify_status pill + expandable
  fingerprint extra). "Load older →" + "← back to most recent" controls.
- DeleteSection — two-step arm/confirm pattern (no native confirm() dialog),
  alarm-tone Button on confirm, success → toast + navigate('/')
- Bottom Strip with "new specimen ↗" link

States: useManageToken loading | ApiError(NOT_FOUND) → ManageNotFound |
other error → ManageError with retry | success → ManageView.

Pagination policy per plan anti-relit: singular useQuery, NO useInfiniteQuery.
Each cursor change replaces the visible page (operator-friendly default).
Auto-refresh every 5s via QUERY_STRATEGIES.frequent (Phase 14 contract).

Routes:
- /  → landing
- /m/:manageId → manage (new)
- *  → notfound (new — replaces previous fallback-to-landing)

config.ts: ROUTES.MANAGE added; manageRoute(id) helper centralizes the
URI-encoded path so result.tsx and any future caller cannot drift.

notfound page: same ethos in miniature. 404 strip pill, big OFF-ROUTE
headline, link back to intake.

Gates green: typecheck + biome + stylelint + build (414ms, manage chunk
10.9KB / 3.7KB gzip, notfound chunk 1.0KB / 0.5KB gzip).
This commit is contained in:
CarterPerez-dev 2026-05-17 14:54:27 -04:00
parent 5562f82f34
commit 4a4f465d24
9 changed files with 1088 additions and 2 deletions

View File

@ -5,10 +5,15 @@
export const ROUTES = {
HOME: '/',
MANAGE: '/m/:manageId',
} as const
export type Route = typeof ROUTES
export function manageRoute(manageId: string): string {
return `/m/${encodeURIComponent(manageId)}`
}
export const QUERY_CONFIG = {
STALE_TIME: {
USER: 1000 * 60 * 5,

View File

@ -15,9 +15,13 @@ const routes: RouteObject[] = [
path: ROUTES.HOME,
lazy: () => import('@/pages/landing'),
},
{
path: ROUTES.MANAGE,
lazy: () => import('@/pages/manage'),
},
{
path: '*',
lazy: () => import('@/pages/landing'),
lazy: () => import('@/pages/notfound'),
},
],
},

View File

@ -14,6 +14,7 @@ import {
SpecimenCard,
SpecimenCardSection,
} from '@/components'
import { manageRoute } from '@/config'
import { ArtifactDisplay } from './artifact'
import { ARTIFACT_LABEL, PAGE_COPY } from './copy'
import styles from './landing.module.scss'
@ -30,7 +31,7 @@ export function ResultView({
filenameFallback,
}: ResultViewProps): React.ReactElement {
const { token, artifact } = data
const managePath = `/m/${token.manage_id}`
const managePath = manageRoute(token.manage_id)
return (
<div className={styles.result}>
<header className={styles.resultHead}>

View File

@ -0,0 +1,40 @@
// ===================
// ©AngelaMos | 2026
// copy.ts
// ===================
export const MANAGE_COPY = {
HEADLINE_QUIET: 'NO MOVEMENT YET',
HEADLINE_LIVE: 'TRAP TRIPPED',
HEADLINE_DISABLED: 'TRAP MUTED',
PURPOSE_QUIET:
'The specimen is in place. Nothing has touched it. Keep this page bookmarked — alerts will fire when something does.',
PURPOSE_LIVE:
'At least one visitor has tripped the trap. Below are the recorded events, newest first.',
PURPOSE_DISABLED:
'This specimen still records events for forensic continuity, but no alerts are dispatched. Re-enable from the operator console if needed.',
EVENT_LOG_TITLE: 'EVENT LOG',
EVENT_LOG_EMPTY:
'No events recorded yet. When something touches the trigger URL, it will appear here.',
EVENT_LOAD_MORE: 'Load older events',
EVENT_LOADING: 'Loading next page…',
DELETE_TITLE: 'TERMINATE SPECIMEN',
DELETE_BODY:
'Cascade-deletes this specimen and every event recorded against it. The trigger URL will no longer fire alerts. This action cannot be undone.',
DELETE_CONFIRM: 'Terminate',
DELETE_CANCEL: 'Keep alive',
DELETE_ARM: 'Mark for termination',
BACK_TO_INTAKE: 'New specimen',
NOT_FOUND_HEADLINE: 'DOSSIER NOT FOUND',
NOT_FOUND_BODY:
'The manage ID does not correspond to an active specimen. It may have been terminated or the URL may be malformed.',
ERROR_HEADLINE: 'CANNOT REACH ARCHIVE',
ERROR_BODY: 'Something went wrong fetching this dossier.',
} as const
export const NOTIFY_TONE = {
sent: 'signal',
pending: 'paper',
failed: 'alarm',
deduped: 'paper',
} as const

View File

@ -0,0 +1,122 @@
// ===================
// ©AngelaMos | 2026
// event-row.tsx
// ===================
import type { EventResponse, GeoView } from '@/api'
import { Pill } from '@/components'
import { NOTIFY_TONE } from './copy'
import styles from './manage.module.scss'
type EventRowProps = {
event: EventResponse
index: number
}
export function EventRow({ event, index }: EventRowProps): React.ReactElement {
const tone = NOTIFY_TONE[event.notify_status] ?? 'paper'
return (
<article className={styles.eventRow}>
<header className={styles.eventHead}>
<span className={styles.eventIndex}>
{String(index).padStart(4, '0')}
</span>
<time className={styles.eventTime} dateTime={event.triggered_at}>
{formatTimestamp(event.triggered_at)}
</time>
<Pill tone={tone} size="sm">
{event.notify_status}
</Pill>
</header>
<div className={styles.eventGrid}>
<EventField label="src ip" mono>
{event.source_ip}
</EventField>
<EventField label="geo">{formatGeo(event.geo)}</EventField>
{event.geo.asn ? (
<EventField label="asn" mono>
AS{event.geo.asn}
{event.geo.asn_org ? ` · ${event.geo.asn_org}` : ''}
</EventField>
) : null}
<EventField label="user agent" wide truncate>
{event.user_agent ?? '—'}
</EventField>
{event.referer ? (
<EventField label="referer" wide truncate>
{event.referer}
</EventField>
) : null}
{event.notified_at ? (
<EventField label="notified" mono>
{formatTimestamp(event.notified_at)}
</EventField>
) : null}
</div>
{hasExtraDetails(event.extra) ? (
<details className={styles.eventExtra}>
<summary>extra fingerprint</summary>
<pre className={styles.eventExtraBody}>
{JSON.stringify(event.extra, null, 2)}
</pre>
</details>
) : null}
</article>
)
}
type EventFieldProps = React.PropsWithChildren<{
label: string
mono?: boolean
wide?: boolean
truncate?: boolean
}>
function EventField({
label,
mono = false,
wide = false,
truncate = false,
children,
}: EventFieldProps): React.ReactElement {
return (
<div
className={styles.eventField}
data-mono={mono}
data-wide={wide}
data-truncate={truncate}
>
<span className={styles.eventLabel}>{label}</span>
<span className={styles.eventValue}>{children}</span>
</div>
)
}
function formatTimestamp(iso: string): string {
try {
const dt = new Date(iso)
return dt
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, 'Z')
} catch (_err) {
return iso
}
}
function formatGeo(geo: GeoView): string {
const parts: string[] = []
if (geo.city) parts.push(geo.city)
if (geo.region) parts.push(geo.region)
if (geo.country) parts.push(geo.country)
if (parts.length === 0) {
return '—'
}
return parts.join(' · ')
}
function hasExtraDetails(extra: unknown): boolean {
if (extra == null) return false
if (typeof extra !== 'object') return false
return Object.keys(extra as Record<string, unknown>).length > 0
}

View File

@ -0,0 +1,425 @@
// ===================
// ©AngelaMos | 2026
// index.tsx
// ===================
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
type ManageResponse,
type ManageTokenView,
useDeleteToken,
useManageToken,
} from '@/api'
import {
Button,
CopyField,
DataRow,
Halftone,
Pill,
SerialBar,
SpecimenCard,
SpecimenCardSection,
Strip,
StripItem,
} from '@/components'
import { ApiError, ApiErrorCode } from '@/core/api'
import { MANAGE_COPY } from './copy'
import { EventRow } from './event-row'
import styles from './manage.module.scss'
const PAGE_SIZE = 20
export function Component(): React.ReactElement {
const params = useParams()
const manageId = params.manageId ?? ''
const [cursor, setCursor] = useState<string | undefined>(undefined)
const query = useManageToken(manageId, { cursor, limit: PAGE_SIZE })
if (manageId.length === 0) {
return <ManageNotFound />
}
if (query.isLoading) {
return <ManageLoading />
}
if (query.error) {
if (
query.error instanceof ApiError &&
query.error.code === ApiErrorCode.NOT_FOUND
) {
return <ManageNotFound />
}
return <ManageError onRetry={() => query.refetch()} />
}
if (!query.data) {
return <ManageNotFound />
}
return (
<ManageView
manageId={manageId}
data={query.data}
cursor={cursor}
isFetchingNextPage={query.isFetching && cursor !== undefined}
onLoadOlder={() => setCursor(query.data?.page.next_cursor)}
onResetCursor={() => setCursor(undefined)}
/>
)
}
Component.displayName = 'Manage'
type ManageViewProps = {
manageId: string
data: ManageResponse
cursor: string | undefined
isFetchingNextPage: boolean
onLoadOlder: () => void
onResetCursor: () => void
}
function ManageView({
manageId,
data,
cursor,
isFetchingNextPage,
onLoadOlder,
onResetCursor,
}: ManageViewProps): React.ReactElement {
const { token, events, events_total, events_silenced_active, page } = data
return (
<div className={styles.page}>
<Strip>
<StripItem label="FIELD STATION">canary</StripItem>
<StripItem label="SECTION">dossier</StripItem>
<StripItem label="SPECIES" inverted>
{token.type}
</StripItem>
</Strip>
<DossierHero token={token} eventsTotal={events_total} />
<Halftone density="sparse" height={18} />
<DossierCard
token={token}
eventsTotal={events_total}
silenced={events_silenced_active}
/>
<EventLogSection
events={events}
cursor={cursor}
hasMore={page.has_more}
isFetchingNextPage={isFetchingNextPage}
onLoadOlder={onLoadOlder}
onResetCursor={onResetCursor}
/>
<DeleteSection manageId={manageId} memo={token.memo} />
<Strip align="left" border="top">
<StripItem label="LINK">
<Link to="/" className={styles.footerLink}>
new specimen
</Link>
</StripItem>
<StripItem label="LICENSE">©AngelaMos 2026</StripItem>
</Strip>
</div>
)
}
type DossierHeroProps = {
token: ManageTokenView
eventsTotal: number
}
function DossierHero({
token,
eventsTotal,
}: DossierHeroProps): React.ReactElement {
const state = resolveState(token, eventsTotal)
const headline =
state === 'live'
? MANAGE_COPY.HEADLINE_LIVE
: state === 'disabled'
? MANAGE_COPY.HEADLINE_DISABLED
: MANAGE_COPY.HEADLINE_QUIET
const purpose =
state === 'live'
? MANAGE_COPY.PURPOSE_LIVE
: state === 'disabled'
? MANAGE_COPY.PURPOSE_DISABLED
: MANAGE_COPY.PURPOSE_QUIET
return (
<header className={styles.hero}>
<p className={styles.heroIndex}>DOSSIER · {token.type.toUpperCase()}</p>
<h1 className={styles.headline} data-state={state}>
{headline}
</h1>
<p className={styles.purpose}>{purpose}</p>
<p className={styles.memo}>{token.memo || 'no memo recorded'}</p>
</header>
)
}
type DossierCardProps = {
token: ManageTokenView
eventsTotal: number
silenced: number
}
function DossierCard({
token,
eventsTotal,
silenced,
}: DossierCardProps): React.ReactElement {
return (
<SpecimenCard
tag={`SN · ${token.id}`}
serial={<SerialBar value={token.id} prefix="SN" />}
>
<SpecimenCardSection label="STATUS">
<DataRow label="armed">
<Pill tone={token.enabled ? 'signal' : 'alarm'}>
{token.enabled ? 'live' : 'disabled'}
</Pill>
</DataRow>
<DataRow label="triggers" emphasize alarm={eventsTotal > 0}>
{eventsTotal}
</DataRow>
<DataRow label="silenced (15m window)" mono>
{silenced}
</DataRow>
<DataRow label="last seen" mono>
{token.last_triggered ? formatTs(token.last_triggered) : 'never'}
</DataRow>
<DataRow label="created" mono>
{formatTs(token.created_at)}
</DataRow>
<DataRow label="alert" emphasize>
{token.alert_channel}
</DataRow>
{token.filename ? (
<DataRow label="filename" mono>
{token.filename}
</DataRow>
) : null}
</SpecimenCardSection>
<SpecimenCardSection label="ENDPOINT">
<CopyField label="TRIGGER" value={token.trigger_url} fullWidth />
</SpecimenCardSection>
</SpecimenCard>
)
}
type EventLogSectionProps = {
events: ManageResponse['events']
cursor: string | undefined
hasMore: boolean
isFetchingNextPage: boolean
onLoadOlder: () => void
onResetCursor: () => void
}
function EventLogSection({
events,
cursor,
hasMore,
isFetchingNextPage,
onLoadOlder,
onResetCursor,
}: EventLogSectionProps): React.ReactElement {
return (
<section className={styles.eventLog}>
<header className={styles.eventLogHead}>
<span className={styles.eventLogIndex}>06 / EVENT LOG</span>
<span className={styles.eventLogRule} aria-hidden="true" />
<span className={styles.eventLogCount}>
{events.length} {events.length === 1 ? 'entry' : 'entries'}
</span>
</header>
{events.length === 0 ? (
<p className={styles.eventEmpty}>{MANAGE_COPY.EVENT_LOG_EMPTY}</p>
) : (
<div className={styles.eventList}>
{events.map((event, idx) => (
<EventRow
key={event.id}
event={event}
index={cursor === undefined ? idx + 1 : idx + 1}
/>
))}
</div>
)}
<footer className={styles.eventLogFoot}>
{cursor !== undefined ? (
<Button variant="ghost" size="sm" onClick={onResetCursor}>
back to most recent
</Button>
) : (
<span />
)}
{hasMore ? (
<Button
variant="ghost"
size="sm"
onClick={onLoadOlder}
busy={isFetchingNextPage}
>
{MANAGE_COPY.EVENT_LOAD_MORE}
</Button>
) : (
<span className={styles.eventEnd}>end of log</span>
)}
</footer>
</section>
)
}
type DeleteSectionProps = {
manageId: string
memo: string
}
function DeleteSection({
manageId,
memo,
}: DeleteSectionProps): React.ReactElement {
const [armed, setArmed] = useState(false)
const del = useDeleteToken()
const navigate = useNavigate()
function confirm(): void {
del.mutate(manageId, {
onSuccess: () => {
toast.success(`Terminated ${memo || 'unnamed specimen'}`)
navigate('/')
},
})
}
return (
<section className={styles.deleteSection}>
<header className={styles.deleteHead}>
<span className={styles.deleteIndex}>07 / TERMINATION</span>
<span className={styles.deleteRule} aria-hidden="true" />
</header>
<h2 className={styles.deleteTitle}>{MANAGE_COPY.DELETE_TITLE}</h2>
<p className={styles.deleteBody}>{MANAGE_COPY.DELETE_BODY}</p>
{armed ? (
<div className={styles.deleteArmed}>
<Button
variant="alarm"
onClick={confirm}
busy={del.isPending}
size="md"
>
{MANAGE_COPY.DELETE_CONFIRM}
</Button>
<Button
variant="ghost"
onClick={() => setArmed(false)}
disabled={del.isPending}
size="md"
>
{MANAGE_COPY.DELETE_CANCEL}
</Button>
</div>
) : (
<Button variant="ghost" onClick={() => setArmed(true)}>
{MANAGE_COPY.DELETE_ARM}
</Button>
)}
</section>
)
}
function ManageLoading(): React.ReactElement {
return (
<div className={styles.page}>
<Strip>
<StripItem label="FIELD STATION">canary</StripItem>
<StripItem label="STATUS">loading dossier</StripItem>
</Strip>
<div className={styles.statusBlock}>fetching specimen record</div>
</div>
)
}
function ManageNotFound(): React.ReactElement {
return (
<div className={styles.page}>
<Strip>
<StripItem label="FIELD STATION">canary</StripItem>
<StripItem label="STATUS" inverted>
unfound
</StripItem>
</Strip>
<header className={styles.hero}>
<p className={styles.heroIndex}>404 · DOSSIER</p>
<h1 className={styles.headline}>{MANAGE_COPY.NOT_FOUND_HEADLINE}</h1>
<p className={styles.purpose}>{MANAGE_COPY.NOT_FOUND_BODY}</p>
</header>
<Link to="/" className={styles.heroLink}>
{MANAGE_COPY.BACK_TO_INTAKE}
</Link>
</div>
)
}
type ManageErrorProps = {
onRetry: () => void
}
function ManageError({ onRetry }: ManageErrorProps): React.ReactElement {
return (
<div className={styles.page}>
<Strip>
<StripItem label="FIELD STATION">canary</StripItem>
<StripItem label="STATUS" inverted>
archive offline
</StripItem>
</Strip>
<header className={styles.hero}>
<p className={styles.heroIndex}>5xx · ARCHIVE</p>
<h1 className={styles.headline}>{MANAGE_COPY.ERROR_HEADLINE}</h1>
<p className={styles.purpose}>{MANAGE_COPY.ERROR_BODY}</p>
</header>
<div className={styles.heroActions}>
<Button onClick={onRetry}>Try again</Button>
<Link to="/" className={styles.heroLink}>
new specimen
</Link>
</div>
</div>
)
}
function resolveState(
token: ManageTokenView,
eventsTotal: number
): 'quiet' | 'live' | 'disabled' {
if (!token.enabled) {
return 'disabled'
}
if (eventsTotal > 0) {
return 'live'
}
return 'quiet'
}
function formatTs(iso: string): string {
try {
const dt = new Date(iso)
return dt
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, 'Z')
} catch (_err) {
return iso
}
}

View File

@ -0,0 +1,378 @@
// ===================
// ©AngelaMos | 2026
// manage.module.scss
// ===================
@use '../../styles/tokens' as *;
@use '../../styles/fonts' as *;
@use '../../styles/mixins' as *;
.page {
display: flex;
flex-direction: column;
flex: 1 1 auto;
inline-size: 100%;
max-inline-size: 88rem;
margin-inline: auto;
padding-inline: $space-4;
padding-block-end: $space-8;
gap: $space-6;
@include breakpoint-up('md') {
padding-inline: $space-8;
gap: $space-8;
}
}
.hero {
display: flex;
flex-direction: column;
gap: $space-3;
padding-block-start: $space-6;
max-inline-size: 56rem;
@include breakpoint-up('md') {
padding-block-start: $space-10;
}
}
.heroIndex {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-mute;
margin: 0;
}
.heroLink {
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink;
text-decoration: none;
border-block-end: $hairline solid $ink;
padding-block-end: $space-0-5;
align-self: flex-start;
transition: color $duration-fast $ease-out;
&:hover {
color: $alarm;
border-block-end-color: $alarm;
}
}
.heroActions {
display: flex;
align-items: center;
gap: $space-4;
flex-wrap: wrap;
}
.headline {
font-family: $font-sans;
font-weight: 900;
font-size: clamp(2.25rem, 8vw, 6rem);
letter-spacing: -0.05em;
line-height: 0.9;
text-transform: uppercase;
color: $ink;
margin: 0;
&[data-state='live'] {
color: $alarm;
}
&[data-state='disabled'] {
color: $ink-mute;
}
}
.purpose {
font-family: $font-sans;
font-size: clamp(0.95rem, 1.5vw, 1.125rem);
line-height: $line-height-snug;
color: $ink;
max-inline-size: 42rem;
margin: 0;
text-wrap: balance;
}
.memo {
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
color: $ink-soft;
padding-inline-start: $space-3;
border-inline-start: $hairline-bold solid $rule;
margin: 0;
max-inline-size: 42rem;
}
// ----------------------------------------------------------------------------
// EVENT LOG
// ----------------------------------------------------------------------------
.eventLog {
display: flex;
flex-direction: column;
gap: $space-4;
}
.eventLogHead {
display: flex;
align-items: center;
gap: $space-3;
}
.eventLogIndex {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-mute;
}
.eventLogRule {
flex: 1 1 auto;
block-size: $hairline;
background-color: $rule-hair;
}
.eventLogCount {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-soft;
}
.eventList {
display: flex;
flex-direction: column;
gap: $space-3;
}
.eventEmpty {
padding: $space-5;
border: $hairline dashed $rule-soft;
background-color: $paper;
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
color: $ink-soft;
text-align: center;
}
.eventLogFoot {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding-block-start: $space-3;
border-block-start: $hairline solid $rule-hair;
}
.eventEnd {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-mute;
}
// ----------------------------------------------------------------------------
// EVENT ROW
// ----------------------------------------------------------------------------
.eventRow {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: $hairline solid $rule;
background-color: $paper;
}
.eventHead {
display: flex;
align-items: center;
gap: $space-3;
flex-wrap: wrap;
padding-block-end: $space-2;
border-block-end: $hairline solid $rule-hair;
}
.eventIndex {
font-family: $font-mono;
font-size: $font-size-2xs;
letter-spacing: $tracking-wider;
color: $ink-mute;
}
.eventTime {
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
color: $ink;
flex: 1 1 auto;
}
.eventGrid {
display: grid;
grid-template-columns: 1fr;
gap: $space-2;
@include breakpoint-up('sm') {
grid-template-columns: repeat(2, 1fr);
}
}
.eventField {
display: flex;
flex-direction: column;
gap: $space-0-5;
min-inline-size: 0;
&[data-wide='true'] {
@include breakpoint-up('sm') {
grid-column: 1 / -1;
}
}
&[data-mono='true'] .eventValue {
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
}
&[data-truncate='true'] .eventValue {
@include truncate;
}
}
.eventLabel {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-mute;
}
.eventValue {
font-family: $font-sans;
font-size: $font-size-sm;
color: $ink;
overflow-wrap: anywhere;
}
.eventExtra {
background-color: $paper-deep;
padding: $space-3;
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
summary {
cursor: pointer;
color: $ink-soft;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
}
}
.eventExtraBody {
margin: $space-2 0 0;
white-space: pre-wrap;
color: $ink;
font-size: $font-size-xs;
overflow: auto;
max-block-size: 16rem;
}
// ----------------------------------------------------------------------------
// DELETE
// ----------------------------------------------------------------------------
.deleteSection {
display: flex;
flex-direction: column;
gap: $space-3;
padding-block-start: $space-6;
}
.deleteHead {
display: flex;
align-items: center;
gap: $space-3;
}
.deleteIndex {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $alarm-deep;
}
.deleteRule {
flex: 1 1 auto;
block-size: $hairline;
background-color: $alarm;
}
.deleteTitle {
font-family: $font-sans;
font-weight: 800;
font-size: clamp(1.25rem, 3vw, 1.75rem);
letter-spacing: -0.02em;
text-transform: uppercase;
color: $alarm-deep;
margin: 0;
}
.deleteBody {
font-size: $font-size-sm;
color: $ink-soft;
max-inline-size: 38rem;
margin: 0;
}
.deleteArmed {
display: flex;
align-items: center;
gap: $space-3;
flex-wrap: wrap;
}
// ----------------------------------------------------------------------------
// FOOTER
// ----------------------------------------------------------------------------
.footerLink {
color: inherit;
text-decoration: none;
border-block-end: $hairline solid $ink;
padding-block-end: $space-0-5;
&:hover {
color: $alarm;
border-block-end-color: $alarm;
}
}
// ----------------------------------------------------------------------------
// LOADING / STATUS
// ----------------------------------------------------------------------------
.statusBlock {
padding: $space-4;
border: $hairline solid $rule-hair;
background-color: $paper-deep;
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wide;
color: $ink-soft;
}

View File

@ -0,0 +1,34 @@
// ===================
// ©AngelaMos | 2026
// index.tsx
// ===================
import { Link } from 'react-router-dom'
import { Strip, StripItem } from '@/components'
import styles from './notfound.module.scss'
export function Component(): React.ReactElement {
return (
<div className={styles.page}>
<Strip>
<StripItem label="FIELD STATION">canary</StripItem>
<StripItem label="STATUS" inverted>
off-route
</StripItem>
</Strip>
<main className={styles.body}>
<p className={styles.index}>404 · OFF-ROUTE</p>
<h1 className={styles.headline}>NO SPECIMEN HERE</h1>
<p className={styles.purpose}>
This path is not part of the catalog. Maybe a typo, maybe a deleted
dossier. The intake is still open.
</p>
<Link to="/" className={styles.link}>
back to intake
</Link>
</main>
</div>
)
}
Component.displayName = 'NotFound'

View File

@ -0,0 +1,77 @@
// ===================
// ©AngelaMos | 2026
// notfound.module.scss
// ===================
@use '../../styles/tokens' as *;
@use '../../styles/fonts' as *;
@use '../../styles/mixins' as *;
.page {
display: flex;
flex-direction: column;
flex: 1 1 auto;
inline-size: 100%;
max-inline-size: 56rem;
margin-inline: auto;
padding-inline: $space-4;
padding-block-end: $space-8;
gap: $space-8;
@include breakpoint-up('md') {
padding-inline: $space-8;
}
}
.body {
display: flex;
flex-direction: column;
gap: $space-4;
padding-block-start: $space-10;
}
.index {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink-mute;
margin: 0;
}
.headline {
font-family: $font-sans;
font-weight: 900;
font-size: clamp(2.25rem, 8vw, 5.5rem);
letter-spacing: -0.05em;
line-height: 0.9;
text-transform: uppercase;
color: $ink;
margin: 0;
}
.purpose {
font-family: $font-sans;
font-size: clamp(0.95rem, 1.5vw, 1.125rem);
color: $ink-soft;
max-inline-size: 42rem;
margin: 0;
text-wrap: balance;
}
.link {
font-family: $font-mono;
font-size: $font-size-xs;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink;
text-decoration: none;
border-block-end: $hairline solid $ink;
padding-block-end: $space-0-5;
align-self: flex-start;
&:hover {
color: $alarm;
border-block-end-color: $alarm;
}
}