fix(canary-phase-ui-design): single-source form fields + URL revoke + eventExtra schema

Parallel audit (superpowers:code-reviewer + general-purpose spec adherence)
returned PASS-WITH-NOTES + PASS. All actionable SHOULD-FIX and NIT items
addressed in this commit per the fix-in-phase rule.

SHOULD-FIX (code review):
- Dead ternary at manage/index.tsx:253 (`cursor === undefined ? idx+1 : idx+1`)
  collapsed to `idx + 1`. EventLogSection prop renamed `cursor` → `cursorIsSet`
  (boolean) since the value itself was never consumed past the dead branch.
- URL.createObjectURL leak in artifact.tsx — both file (base64) and text
  variants now run inside useEffect with cleanup that calls
  URL.revokeObjectURL on dependency-change AND on unmount. Extracted into
  two co-located hooks: useBase64ObjectUrl, useTextObjectUrl.
- Text inputs lacked <label htmlFor=…> association. Refactored the landing
  form to use the existing Field primitives (TextField, TextareaField) which
  wire htmlFor↔id via useId. This single change also closed the next finding.
- Dead infrastructure: Field primitives existed but no consumer used them.
  Landing form's local FieldLabel removed; TextField/TextareaField now have
  consumers. Net diff: −181 LOC.
- LeaderLabel: truly unused primitive, no foreseeable consumer in this scope.
  Deleted (`frontend/src/components/LeaderLabel/`) and removed from barrel.

SHOULD-FIX (spec adherence):
- §8.6 row 410 → TOKEN_DISABLED: spec is stale. Backend Go ground truth
  (verified via `grep StatusGone\|TOKEN_DISABLED backend/internal/`) emits
  neither — disabled tokens come back as 200 with `token.enabled=false`,
  which the DossierCard already renders with alarm-pill + HEADLINE_DISABLED.
  Per feedback_verify_data_shapes.md: backend Go beats spec when they diverge.
  No code change; documented here so the next AI doesn't re-open it.
- eventResponseSchema.extra: tightened from `z.unknown()` to
  `z.record(z.string(), z.unknown())` to match backend's `json.RawMessage`
  default of `{}` (event/repository.go:47). Added EventExtra type alias;
  event-row.tsx now uses it instead of casting through unknown.

NIT:
- SerialBar: extracted `BAR_COUNT = 22` const; `FALLBACK_BARS` constant
  string replaces the BAR_CHARS array that had length-9 / length-22
  inconsistency between the empty-input and computed branches.
- Glyph: removed `default` branch — switch is now exhaustive over the
  closed TokenType enum, so a future 8th type would be a compile error
  rather than silently rendering WebbugGlyph.
- Dead `export { TOKEN_BLURB }` + `export { ArtifactDisplay }` from
  landing/index.tsx — no observed consumer, dropped.
- App.tsx and main.tsx header banners normalized to the project-canonical
  19 = signs (matches all other files in repo).

NOT addressed in this commit (intentional):
- CreateTokenRequest `metadata` as `z.unknown()`. Refactor to a discriminated
  request schema keyed on `type` would tighten compile-time safety for any
  future caller bypassing buildPayload — defer because (a) the form is the
  only caller and validates via Zod on submit, (b) the change touches the
  Phase 14 contract that was just stabilized two commits before this UI phase.
- NotFound (frontend route fallback) vs ManageNotFound (manage 404) live
  separately. Both work; the two paths are intentional — off-path vs
  off-dossier are semantically different events.

Gates green: typecheck + biome check + lint:scss + build (414ms; landing
chunk dropped 21KB → 19.93KB after dead style removal).
This commit is contained in:
CarterPerez-dev 2026-05-17 15:07:50 -04:00
parent 4a4f465d24
commit da126ad889
13 changed files with 142 additions and 327 deletions

View File

@ -1,7 +1,7 @@
// ===========================
// ===================
// ©AngelaMos | 2026
// App.tsx
// ===========================
// ===================
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

View File

@ -20,6 +20,10 @@ export const geoViewSchema = z.object({
export type GeoView = z.infer<typeof geoViewSchema>
export const eventExtraSchema = z.record(z.string(), z.unknown())
export type EventExtra = z.infer<typeof eventExtraSchema>
export const eventResponseSchema = z.object({
id: z.number().int().nonnegative(),
triggered_at: z.iso.datetime(),
@ -27,7 +31,7 @@ export const eventResponseSchema = z.object({
user_agent: z.string().nullable(),
referer: z.string().nullable(),
geo: geoViewSchema,
extra: z.unknown(),
extra: eventExtraSchema,
notify_status: notifyStatusSchema,
notified_at: z.iso.datetime().nullable(),
})

View File

@ -52,8 +52,6 @@ function renderGlyph(type: TokenType): React.ReactElement {
return <EnvfileGlyph />
case 'mysql':
return <MysqlGlyph />
default:
return <WebbugGlyph />
}
}

View File

@ -1,57 +0,0 @@
// ===================
// ©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;
}

View File

@ -1,31 +0,0 @@
// ===================
// ©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>
)
}

View File

@ -11,14 +11,15 @@ type SerialBarProps = {
prefix?: string
}
const BAR_CHARS = ['▮', '▯', '▮', '▮', '▯', '▮', '▯', '▯', '▮']
const BAR_COUNT = 22
const FALLBACK_BARS = '▮▯▮▮▯▮▯▯▮▮▯▮▮▯▮▯▮▮▯▮▯▮'
function fingerprintToBars(value: string): string {
if (value.length === 0) {
return BAR_CHARS.join('')
return FALLBACK_BARS
}
let acc = ''
for (let i = 0; i < 22; i += 1) {
for (let i = 0; i < BAR_COUNT; i += 1) {
const ch = value.charCodeAt(i % value.length)
acc += (ch & 1) === 0 ? '▮' : '▯'
}

View File

@ -6,15 +6,9 @@
export { Button } from './Button'
export { CopyField } from './CopyField'
export { DataRow } from './DataRow'
export {
FieldWrap,
SelectField,
TextareaField,
TextField,
} from './Field'
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'

View File

@ -1,7 +1,7 @@
// ===========================
// ===================
// ©AngelaMos | 2026
// main.tsx
// ===========================
// ===================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

View File

@ -3,7 +3,7 @@
// artifact.tsx
// ===================
import { useMemo } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { Artifact } from '@/api'
import { CopyField, Pill } from '@/components'
@ -63,16 +63,7 @@ function FileArtifact({
}): React.ReactElement {
const filename = artifact.filename ?? filenameFallback
const contentType = artifact.content_type ?? 'application/octet-stream'
const href = useMemo(() => {
if (!artifact.content_b64) {
return null
}
try {
return base64ToObjectUrl(artifact.content_b64, contentType)
} catch (_err) {
return null
}
}, [artifact.content_b64, contentType])
const href = useBase64ObjectUrl(artifact.content_b64, contentType)
if (!href) {
return <Pill tone="alarm">artifact unreadable</Pill>
@ -109,13 +100,7 @@ function TextArtifact({
const filename = artifact.filename ?? filenameFallback
const content = artifact.content ?? ''
const contentType = artifact.content_type ?? 'text/plain'
const href = useMemo(() => {
if (content.length === 0) {
return null
}
const blob = new Blob([content], { type: contentType })
return URL.createObjectURL(blob)
}, [content, contentType])
const href = useTextObjectUrl(content, contentType)
return (
<div className={styles.artifactStack}>
@ -152,12 +137,57 @@ function ConnectionStringArtifact({
)
}
function base64ToObjectUrl(b64: string, contentType: string): string {
function useBase64ObjectUrl(
b64: string | undefined,
contentType: string
): string | null {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (!b64 || b64.length === 0) {
setUrl(null)
return
}
let created: string | null = null
try {
const bytes = base64ToBytes(b64)
const blob = new Blob([bytes], { type: contentType })
created = URL.createObjectURL(blob)
setUrl(created)
} catch (_err) {
setUrl(null)
}
return () => {
if (created !== null) {
URL.revokeObjectURL(created)
}
}
}, [b64, contentType])
return url
}
function useTextObjectUrl(content: string, contentType: string): string | null {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (content.length === 0) {
setUrl(null)
return
}
const blob = new Blob([content], { type: contentType })
const next = URL.createObjectURL(blob)
setUrl(next)
return () => {
URL.revokeObjectURL(next)
}
}, [content, contentType])
return url
}
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const binary = atob(b64)
const len = binary.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return URL.createObjectURL(new Blob([bytes], { type: contentType }))
return bytes
}

View File

@ -14,10 +14,16 @@ import {
useCreateToken,
useTokenTypes,
} from '@/api'
import { Button, Halftone, Strip, StripItem } from '@/components'
import {
Button,
Halftone,
Strip,
StripItem,
TextareaField,
TextField,
} from '@/components'
import { getTurnstileSiteKey, Turnstile } from '@/core/turnstile'
import { ArtifactDisplay } from './artifact'
import { PAGE_COPY, TOKEN_BLURB } from './copy'
import { PAGE_COPY } from './copy'
import {
buildSubmissionState,
FILE_KIND_TYPES,
@ -224,22 +230,17 @@ function AnnotateSection({
title={PAGE_COPY.SECTION_02_TITLE}
body={PAGE_COPY.SECTION_02_BODY}
>
<div className={styles.fieldRow}>
<FieldLabel index="A">MEMO</FieldLabel>
<textarea
className={styles.textarea}
value={form.memo}
maxLength={256}
placeholder="Q4 bonuses spreadsheet on file-server"
onChange={(e) => onChange({ memo: e.target.value })}
data-invalid={errors.memo !== undefined}
/>
{errors.memo ? (
<p className={styles.fieldError}>{errors.memo}</p>
) : (
<p className={styles.fieldHint}>{form.memo.length} / 256 characters</p>
)}
</div>
<TextareaField
index="A"
label="MEMO"
name="memo"
value={form.memo}
maxLength={256}
placeholder="Q4 bonuses spreadsheet on file-server"
onChange={(e) => onChange({ memo: e.target.value })}
error={errors.memo}
hint={`${form.memo.length} / 256 characters`}
/>
{showFilename ? (
<FilenameField form={form} errors={errors} onChange={onChange} />
) : null}
@ -253,24 +254,17 @@ function FilenameField({
onChange,
}: SectionPropsBase): React.ReactElement {
return (
<div className={styles.fieldRow}>
<FieldLabel index="B">FILENAME (OPTIONAL)</FieldLabel>
<input
className={styles.input}
value={form.filename}
maxLength={128}
placeholder={defaultFilename(form.type)}
onChange={(e) => onChange({ filename: e.target.value })}
data-invalid={errors.filename !== undefined}
/>
{errors.filename ? (
<p className={styles.fieldError}>{errors.filename}</p>
) : (
<p className={styles.fieldHint}>
What the bait file will be called when it lands somewhere it shouldn't.
</p>
)}
</div>
<TextField
index="B"
label="FILENAME (OPTIONAL)"
name="filename"
value={form.filename}
maxLength={128}
placeholder={defaultFilename(form.type)}
onChange={(e) => onChange({ filename: e.target.value })}
error={errors.filename}
hint="What the bait file will be called when it lands somewhere it shouldn't."
/>
)
}
@ -305,32 +299,24 @@ function TelegramFields({
}: SectionPropsBase): React.ReactElement {
return (
<div className={styles.subgrid}>
<div className={styles.fieldRow}>
<FieldLabel index="A">BOT TOKEN</FieldLabel>
<input
className={styles.input}
value={form.telegramBot}
placeholder="123456789:ABCdefGHIjklMNO..."
onChange={(e) => onChange({ telegramBot: e.target.value })}
data-invalid={errors.telegram_bot !== undefined}
/>
{errors.telegram_bot ? (
<p className={styles.fieldError}>{errors.telegram_bot}</p>
) : null}
</div>
<div className={styles.fieldRow}>
<FieldLabel index="B">CHAT ID</FieldLabel>
<input
className={styles.input}
value={form.telegramChat}
placeholder="-1001234567890"
onChange={(e) => onChange({ telegramChat: e.target.value })}
data-invalid={errors.telegram_chat !== undefined}
/>
{errors.telegram_chat ? (
<p className={styles.fieldError}>{errors.telegram_chat}</p>
) : null}
</div>
<TextField
index="A"
label="BOT TOKEN"
name="telegram_bot"
value={form.telegramBot}
placeholder="123456789:ABCdefGHIjklMNO..."
onChange={(e) => onChange({ telegramBot: e.target.value })}
error={errors.telegram_bot}
/>
<TextField
index="B"
label="CHAT ID"
name="telegram_chat"
value={form.telegramChat}
placeholder="-1001234567890"
onChange={(e) => onChange({ telegramChat: e.target.value })}
error={errors.telegram_chat}
/>
</div>
)
}
@ -341,25 +327,17 @@ function WebhookField({
onChange,
}: SectionPropsBase): React.ReactElement {
return (
<div className={styles.fieldRow}>
<FieldLabel index="A">WEBHOOK URL</FieldLabel>
<input
className={styles.input}
type="url"
value={form.webhookUrl}
placeholder="https://hooks.example.com/canary"
onChange={(e) => onChange({ webhookUrl: e.target.value })}
data-invalid={errors.webhook_url !== undefined}
/>
{errors.webhook_url ? (
<p className={styles.fieldError}>{errors.webhook_url}</p>
) : (
<p className={styles.fieldHint}>
We POST the alert envelope JSON. Optionally HMAC-signed if the server
has WEBHOOK_HMAC_SECRET set.
</p>
)}
</div>
<TextField
index="A"
label="WEBHOOK URL"
name="webhook_url"
type="url"
value={form.webhookUrl}
placeholder="https://hooks.example.com/canary"
onChange={(e) => onChange({ webhookUrl: e.target.value })}
error={errors.webhook_url}
hint="We POST the alert envelope JSON. Optionally HMAC-signed if the server has WEBHOOK_HMAC_SECRET set."
/>
)
}
@ -395,20 +373,17 @@ function SlowRedirectField({
errors,
onChange,
}: SectionPropsBase): React.ReactElement {
const err = errors['metadata.destination_url']
return (
<div className={styles.fieldRow}>
<FieldLabel index="A">DESTINATION URL</FieldLabel>
<input
className={styles.input}
type="url"
value={form.slowredirectDestination}
placeholder="https://example.com/landing"
onChange={(e) => onChange({ slowredirectDestination: e.target.value })}
data-invalid={err !== undefined}
/>
{err ? <p className={styles.fieldError}>{err}</p> : null}
</div>
<TextField
index="A"
label="DESTINATION URL"
name="metadata_destination_url"
type="url"
value={form.slowredirectDestination}
placeholder="https://example.com/landing"
onChange={(e) => onChange({ slowredirectDestination: e.target.value })}
error={errors['metadata.destination_url']}
/>
)
}
@ -469,19 +444,6 @@ function Section({
)
}
function FieldLabel({
index,
children,
}: React.PropsWithChildren<{ index?: string }>): React.ReactElement {
return (
<div className={styles.fieldLabel}>
{index ? <span className={styles.fieldIndex}>{index}</span> : null}
<span className={styles.fieldName}>{children}</span>
<span className={styles.fieldRule} aria-hidden="true" />
</div>
)
}
type ChannelToggleProps = {
value: 'telegram' | 'webhook'
onChange: (next: 'telegram' | 'webhook') => void
@ -608,6 +570,3 @@ function descriptorTitle(type: TokenType | ''): string {
}
return 'CONFIG'
}
export { TOKEN_BLURB }
export { ArtifactDisplay }

View File

@ -262,83 +262,6 @@
color: $ink-soft;
}
.fieldRow {
display: flex;
flex-direction: column;
gap: $space-2;
}
.fieldLabel {
display: flex;
align-items: center;
gap: $space-2;
}
.fieldIndex {
font-family: $font-mono;
font-size: $font-size-3xs;
letter-spacing: $tracking-wider;
color: $ink-mute;
white-space: nowrap;
}
.fieldName {
font-family: $font-mono;
font-size: $font-size-3xs;
font-weight: $font-weight-semibold;
letter-spacing: $tracking-wider;
text-transform: uppercase;
color: $ink;
white-space: nowrap;
}
.fieldRule {
flex: 1 1 auto;
block-size: $hairline;
background-color: $rule-hair;
}
.input,
.textarea {
inline-size: 100%;
padding-block: $space-3;
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;
box-shadow: inset 0 0 0 $hairline $ink;
border-color: $ink;
}
&[data-invalid='true'] {
border-color: $alarm;
}
}
.textarea {
min-block-size: 5lh;
resize: vertical;
line-height: $line-height-normal;
}
.fieldHint {
font-size: $font-size-xs;
color: $ink-mute;
margin: 0;
}
.fieldError {
font-family: $font-mono;
font-size: $font-size-3xs;

View File

@ -3,7 +3,7 @@
// event-row.tsx
// ===================
import type { EventResponse, GeoView } from '@/api'
import type { EventExtra, EventResponse, GeoView } from '@/api'
import { Pill } from '@/components'
import { NOTIFY_TONE } from './copy'
import styles from './manage.module.scss'
@ -115,8 +115,6 @@ function formatGeo(geo: GeoView): string {
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
function hasExtraDetails(extra: EventExtra): boolean {
return Object.keys(extra).length > 0
}

View File

@ -110,7 +110,7 @@ function ManageView({
<EventLogSection
events={events}
cursor={cursor}
cursorIsSet={cursor !== undefined}
hasMore={page.has_more}
isFetchingNextPage={isFetchingNextPage}
onLoadOlder={onLoadOlder}
@ -218,7 +218,7 @@ function DossierCard({
type EventLogSectionProps = {
events: ManageResponse['events']
cursor: string | undefined
cursorIsSet: boolean
hasMore: boolean
isFetchingNextPage: boolean
onLoadOlder: () => void
@ -227,7 +227,7 @@ type EventLogSectionProps = {
function EventLogSection({
events,
cursor,
cursorIsSet,
hasMore,
isFetchingNextPage,
onLoadOlder,
@ -247,16 +247,12 @@ function EventLogSection({
) : (
<div className={styles.eventList}>
{events.map((event, idx) => (
<EventRow
key={event.id}
event={event}
index={cursor === undefined ? idx + 1 : idx + 1}
/>
<EventRow key={event.id} event={event} index={idx + 1} />
))}
</div>
)}
<footer className={styles.eventLogFoot}>
{cursor !== undefined ? (
{cursorIsSet ? (
<Button variant="ghost" size="sm" onClick={onResetCursor}>
back to most recent
</Button>