Skip to content
Product block

Cascade Delete Dialog

Deleting a record other records depend on: dependents counted and grouped by fate - deleted, orphaned, retained - with a guard that scales to the blast radius.

262 lineslucide-reactAdded 13 Sept 2026
  • crud
  • delete
  • destructive
  • dialog
  • cascade

What's included

  • components/crud-delete-cascade.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add crud-delete-cascade

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Delete the workspace “Rotterdam Operations”?

77 linked records are affected. 47 of them are deleted outright and cannot be restored after 30 days.

  • Deleted

    41 Documents

    Removed with the workspace. Not recoverable after 30 days.

  • Deleted

    6 Integrations

    Webhooks stop firing immediately. Third parties are not notified.

  • Orphaned

    18 Shared links

    Stay live and start returning 404. Anyone holding one sees a dead page.

  • Retained

    12 Invoices

    Kept for seven years under statutory retention. Detached from the workspace.

Two safer options

Asked because more than 10 records are affected. Smaller deletes get a plain confirm.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/crud-delete-cascade.tsx
'use client'

/**
 * <CrudDeleteCascade> — Delete, when the record is not alone.
 *
 * `confirm-dialog` in this catalog covers the general case: a destructive
 * action, a type-to-confirm guard, a verb on the button. This is the case it
 * deliberately does not cover — deleting a record that other records depend
 * on, where the honest question is not "are you sure" but "do you know what
 * else goes".
 *
 * THE FAILURE THIS PREVENTS
 *
 * A user deletes a workspace expecting to remove an empty shell and takes 41
 * documents, 6 integrations and a year of audit history with it. They were
 * asked "are you sure", they were sure, and they were wrong — because the
 * question tested their confidence rather than their knowledge.
 *
 * So the dialog leads with the dependents, counted, itemised and grouped by
 * what happens to them. Three fates, and they must be visually distinct:
 *
 *   deleted     goes with the record
 *   orphaned    survives, loses its link, becomes someone's problem later
 *   retained    kept deliberately, usually because law says so
 *
 * "Orphaned" is the one every product forgets to tell anyone about, and it is
 * the one that generates the support ticket six weeks later.
 *
 * THE ALTERNATIVE IS OFFERED, NOT BURIED
 *
 * Most people reaching a cascade dialog want the record gone, not its
 * children. Transfer and archive are offered as first-class buttons in the
 * dialog, because sending someone away to find the safer action is how they
 * end up pressing the dangerous one.
 *
 * THE GUARD SCALES WITH THE BLAST RADIUS. Under ten dependents, a plain
 * confirm. Over it, type the record's name. A type-to-confirm on every delete
 * trains people to type without reading; reserving it for the ones that
 * matter keeps it meaningful.
 *
 * ACCESSIBILITY: `role="alertdialog"` — not `dialog` — because this
 * interrupts to warn; described by the consequence summary, so a screen
 * reader hears the blast radius with the title. The confirm button is
 * disabled until the guard passes, and says what it deletes.
 */

import * as React from 'react'
import { ArchiveRestore, TriangleAlert, Unlink, UserRoundCog, X } from 'lucide-react'

export type Fate = 'deleted' | 'orphaned' | 'retained'

export interface Dependent {
  id: string
  label: string
  count: number
  fate: Fate
  note: string
}

export interface CrudDeleteCascadeProps {
  recordName?: string
  recordKind?: string
  dependents?: Dependent[]
  /** Dependents above this switch the guard to type-to-confirm. */
  guardThreshold?: number
  className?: string
}

const DEFAULT_DEPENDENTS: Dependent[] = [
  {
    id: 'docs',
    label: 'Documents',
    count: 41,
    fate: 'deleted',
    note: 'Removed with the workspace. Not recoverable after 30 days.',
  },
  {
    id: 'integrations',
    label: 'Integrations',
    count: 6,
    fate: 'deleted',
    note: 'Webhooks stop firing immediately. Third parties are not notified.',
  },
  {
    id: 'shared-links',
    label: 'Shared links',
    count: 18,
    fate: 'orphaned',
    note: 'Stay live and start returning 404. Anyone holding one sees a dead page.',
  },
  {
    id: 'invoices',
    label: 'Invoices',
    count: 12,
    fate: 'retained',
    note: 'Kept for seven years under statutory retention. Detached from the workspace.',
  },
]

const FATE_META: Record<Fate, { label: string; className: string; icon: typeof Unlink }> = {
  deleted: {
    label: 'Deleted',
    className: 'bg-destructive/10 text-destructive',
    icon: TriangleAlert,
  },
  orphaned: {
    label: 'Orphaned',
    className: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
    icon: Unlink,
  },
  retained: {
    label: 'Retained',
    className: 'bg-muted text-muted-foreground',
    icon: ArchiveRestore,
  },
}

export function CrudDeleteCascade({
  recordName = 'Rotterdam Operations',
  recordKind = 'workspace',
  dependents = DEFAULT_DEPENDENTS,
  guardThreshold = 10,
  className = '',
}: CrudDeleteCascadeProps) {
  const uid = React.useId()
  const [typed, setTyped] = React.useState('')

  const total = dependents.reduce((sum, d) => sum + d.count, 0)
  const destroyed = dependents
    .filter((d) => d.fate === 'deleted')
    .reduce((sum, d) => sum + d.count, 0)
  // The guard scales with the blast radius — see the docblock.
  const needsTyping = total > guardThreshold
  const armed = !needsTyping || typed.trim() === recordName

  return (
    <section
      className={`flex min-h-[30rem] items-center justify-center bg-muted/30 p-4 sm:p-6 ${className}`}
    >
      <div
        role="alertdialog"
        aria-modal="true"
        aria-labelledby={`${uid}-title`}
        aria-describedby={`${uid}-summary`}
        className="w-full max-w-lg rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
      >
        <header className="flex items-start justify-between gap-3 border-b border-border p-5">
          <div className="flex gap-3">
            <span
              aria-hidden
              className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive"
            >
              <TriangleAlert className="h-5 w-5" />
            </span>
            <div>
              <h2 id={`${uid}-title`} className="text-base font-semibold">
                Delete the {recordKind} &ldquo;{recordName}&rdquo;?
              </h2>
              <p id={`${uid}-summary`} className="mt-1 text-sm text-muted-foreground">
                {total} linked records are affected. {destroyed} of them are
                deleted outright and cannot be restored after 30 days.
              </p>
            </div>
          </div>
          <button
            type="button"
            aria-label="Close"
            className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <X aria-hidden className="h-4 w-4" />
          </button>
        </header>

        <div className="space-y-4 p-5">
          <ul className="divide-y divide-border/70 rounded-xl border border-border">
            {dependents.map((dependent) => {
              const meta = FATE_META[dependent.fate]
              const Icon = meta.icon
              return (
                <li key={dependent.id} className="flex gap-3 p-3">
                  <span
                    className={`mt-0.5 inline-flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide ${meta.className}`}
                  >
                    <Icon aria-hidden className="h-3 w-3" />
                    {meta.label}
                  </span>
                  <div className="min-w-0">
                    <p className="text-sm font-medium">
                      {dependent.count} {dependent.label}
                    </p>
                    <p className="mt-0.5 text-xs text-muted-foreground">{dependent.note}</p>
                  </div>
                </li>
              )
            })}
          </ul>

          {/* Offered here, not buried behind Cancel. */}
          <div className="rounded-xl border border-dashed border-border p-3">
            <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
              Two safer options
            </p>
            <div className="mt-2 flex flex-wrap gap-2">
              <button
                type="button"
                className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <UserRoundCog aria-hidden className="h-3.5 w-3.5" />
                Transfer to another owner
              </button>
              <button
                type="button"
                className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <ArchiveRestore aria-hidden className="h-3.5 w-3.5" />
                Archive instead — keeps everything, hides it
              </button>
            </div>
          </div>

          {needsTyping ? (
            <div>
              <label htmlFor={`${uid}-guard`} className="block text-sm font-medium">
                Type <span className="font-mono text-xs">{recordName}</span> to confirm
              </label>
              <input
                id={`${uid}-guard`}
                value={typed}
                onChange={(e) => setTyped(e.target.value)}
                autoComplete="off"
                spellCheck={false}
                aria-describedby={`${uid}-guard-note`}
                className="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-ring"
              />
              <p id={`${uid}-guard-note`} className="mt-1.5 text-xs text-muted-foreground">
                Asked because more than {guardThreshold} records are affected.
                Smaller deletes get a plain confirm.
              </p>
            </div>
          ) : null}
        </div>

        <footer className="flex flex-wrap items-center justify-end gap-2 border-t border-border p-5">
          <button
            type="button"
            className="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Cancel
          </button>
          <button
            type="button"
            disabled={!armed}
            className="rounded-lg bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
          >
            Delete {recordKind} and {destroyed} records
          </button>
        </footer>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/crud-delete-cascade.tsx and import it where you need the section:

import { CrudDeleteCascade } from '@/components/crud-delete-cascade'

Customize

3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
recordNamestring'Rotterdam Operations'
recordKindstring'workspace'
dependentsDependent[]DEFAULT_DEPENDENTS
guardThresholdDependents above this switch the guard to type-to-confirm.number10
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

crud-delete-cascade.html
<!--
  Cascade Delete Dialog — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="flex min-h-[30rem] items-center justify-center bg-muted/30 p-4 sm:p-6 ">
  <div role="alertdialog" aria-modal="true" aria-labelledby="_R_0_-title" aria-describedby="_R_0_-summary" class="w-full max-w-lg rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
    <header class="flex items-start justify-between gap-3 border-b border-border p-5">
      <div class="flex gap-3">
        <span aria-hidden="true" class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-5 w-5" aria-hidden="true">
            <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
            <path d="M12 9v4"></path>
            <path d="M12 17h.01"></path>
          </svg>
        </span>
        <div>
          <h2 id="_R_0_-title" class="text-base font-semibold">Delete the workspace “Rotterdam Operations”?</h2>
          <p id="_R_0_-summary" class="mt-1 text-sm text-muted-foreground">77 linked records are affected. 47 of them are deleted outright and cannot be restored after 30 days.</p>
        </div>
      </div>
      <button type="button" aria-label="Close" class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true">
          <path d="M18 6 6 18"></path>
          <path d="m6 6 12 12"></path>
        </svg>
      </button>
    </header>
    <div class="space-y-4 p-5">
      <ul class="divide-y divide-border/70 rounded-xl border border-border">
        <li class="flex gap-3 p-3">
          <span class="mt-0.5 inline-flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide bg-destructive/10 text-destructive"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-3 w-3" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>Deleted</span>
          <div class="min-w-0">
            <p class="text-sm font-medium">41 Documents</p>
            <p class="mt-0.5 text-xs text-muted-foreground">Removed with the workspace. Not recoverable after 30 days.</p>
          </div>
        </li>
        <li class="flex gap-3 p-3">
          <span class="mt-0.5 inline-flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide bg-destructive/10 text-destructive"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-3 w-3" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>Deleted</span>
          <div class="min-w-0">
            <p class="text-sm font-medium">6 Integrations</p>
            <p class="mt-0.5 text-xs text-muted-foreground">Webhooks stop firing immediately. Third parties are not notified.</p>
          </div>
        </li>
        <li class="flex gap-3 p-3">
          <span class="mt-0.5 inline-flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide bg-amber-500/10 text-amber-700 dark:text-amber-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unlink h-3 w-3" aria-hidden="true"><path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71"></path><path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71"></path><line x1="8" x2="8" y1="2" y2="5"></line><line x1="2" x2="5" y1="8" y2="8"></line><line x1="16" x2="16" y1="19" y2="22"></line><line x1="19" x2="22" y1="16" y2="16"></line></svg>Orphaned</span>
          <div class="min-w-0">
            <p class="text-sm font-medium">18 Shared links</p>
            <p class="mt-0.5 text-xs text-muted-foreground">Stay live and start returning 404. Anyone holding one sees a dead page.</p>
          </div>
        </li>
        <li class="flex gap-3 p-3">
          <span class="mt-0.5 inline-flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide bg-muted text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-archive-restore h-3 w-3" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"></rect><path d="M4 8v11a2 2 0 0 0 2 2h2"></path><path d="M20 8v11a2 2 0 0 1-2 2h-2"></path><path d="m9 15 3-3 3 3"></path><path d="M12 12v9"></path></svg>Retained</span>
          <div class="min-w-0">
            <p class="text-sm font-medium">12 Invoices</p>
            <p class="mt-0.5 text-xs text-muted-foreground">Kept for seven years under statutory retention. Detached from the workspace.</p>
          </div>
        </li>
      </ul>
      <div class="rounded-xl border border-dashed border-border p-3">
        <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Two safer options</p>
        <div class="mt-2 flex flex-wrap gap-2">
          <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round-cog h-3.5 w-3.5" aria-hidden="true"><path d="m14.305 19.53.923-.382"></path><path d="m15.228 16.852-.923-.383"></path><path d="m16.852 15.228-.383-.923"></path><path d="m16.852 20.772-.383.924"></path><path d="m19.148 15.228.383-.923"></path><path d="m19.53 21.696-.382-.924"></path><path d="M2 21a8 8 0 0 1 10.434-7.62"></path><path d="m20.772 16.852.924-.383"></path><path d="m20.772 19.148.924.383"></path><circle cx="10" cy="8" r="5"></circle><circle cx="18" cy="18" r="3"></circle></svg>Transfer to another owner</button>
          <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-archive-restore h-3.5 w-3.5" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"></rect><path d="M4 8v11a2 2 0 0 0 2 2h2"></path><path d="M20 8v11a2 2 0 0 1-2 2h-2"></path><path d="m9 15 3-3 3 3"></path><path d="M12 12v9"></path></svg>Archive instead — keeps everything, hides it</button>
        </div>
      </div>
      <div>
        <label for="_R_0_-guard" class="block text-sm font-medium">Type <span class="font-mono text-xs">Rotterdam Operations</span> to confirm</label>
        <input id="_R_0_-guard" autoComplete="off" spellCheck="false" aria-describedby="_R_0_-guard-note" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-ring" value="" />
        <p id="_R_0_-guard-note" class="mt-1.5 text-xs text-muted-foreground">Asked because more than 10 records are affected. Smaller deletes get a plain confirm.</p>
      </div>
    </div>
    <footer class="flex flex-wrap items-center justify-end gap-2 border-t border-border p-5">
      <button type="button" class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Cancel</button>
      <button type="button" disabled="" class="rounded-lg bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">Delete workspace and 47 records</button>
    </footer>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

11 more blocks in CRUD

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse CRUD

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More CRUD blocks

View category
Open the full page for this block

Create Record Form

A full-page create form with a guidance rail and a Save-and-create-another that keeps the shared fields - the shape a record with more than six fields actually needs.

CRUD408 lines1 dep
Open the full page for this block

Create Record Modal

Three fields over the list they add to, with the new row appearing behind the dialog rather than after it closes.

CRUD262 lines1 dep
Open the full page for this block

Create Record Drawer

A long create form in a side drawer: the fields scroll, the header and the action bar do not, and the list stays legible beside it.

CRUD336 lines1 dep
Open the full page for this block

Record Detail Page

The read view arranged by what a reader came for - a verdict band of four facts, then details, then the related records a field dump can never show.

CRUD314 lines1 dep
Open the full page for this block

Record Quick Look

The verification modal from a table row: copyable identifiers, no editing, nothing scrolling, and the row still marked behind it.

CRUD244 lines1 dep
Open the full page for this block

Edit Record Form

An edit page that shows the diff - per-field change markers carrying the previous value, a per-field revert, and a Save button that counts what it will write.

CRUD259 lines1 dep
Open the full page for this block

Edit Record Modal

The edit modal including the parts nobody draws: the in-flight state, the rejected save that keeps your typing, and the conflict when someone else got there first.

CRUD248 lines1 dep
Open the full page for this block

Field-Level Edit Drawer

The save-on-blur model, with per-field confirmation, per-field failure and a written-in-this-session log - the only honest answer to what did I just change.

CRUD327 lines1 dep
Open the full page for this block

Record Created State

The screen after the write: the reference copyable, next actions ranked rather than listed, and an undo window that counts down honestly and then says why it is gone.

CRUD260 lines1 dep
Open the full page for this block

Bulk Edit Drawer

One change applied to many records that disagree - mixed values shown as mixed, untouched fields never written, and skipped rows counted before you commit.

CRUD229 lines1 dep
Open the full page for this block

Archive and Restore

The bin with the clock on it: days until purge per row, who archived it, and a restore disabled with a reason when the parent has already gone.

CRUD277 lines1 dep