zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Lost Layouts Inventory

Catalog of inbox and archives layouts that existed on main before the frameset rewrite (W5.2 / W5.3). Used as the spec for Sub 10 (#1495) and Sub 11 (#1496).

Note

This document is a read-only research artifact produced by Sub 9 (#1486). Sub 10 (#1495) and Sub 11 (#1496) implement against the catalog below without re-reading main.

Caution

Partial update — inbox timeline restored. The Background section below states "the inbox timeline layouts were NOT restored and remain unavailable". This is no longer accurate. Sub 10 (#1495, Epic #1482) restored the inbox timeline layout:tauri-app/renderer/components/inbox-layouts/inbox-timeline-layout.tsx now exists and is registered as the inbox.timeline layout in the core.inboxprovider. The horizontal-scroll variant (inbox.timeline-horizontal) status may differ — verify in the current codebase. The archives and other sections remain accurate.

Background

The frameset rewrite (Wave 5) replaced bespoke page-level layouts with composable ViewProvider leaves mounted inside a Frameset shell:

  • W5.2 (85a94cc2) — inbox migrated from WritePageContent (which owned TimelineView as an inline toggle) to a frameset of core.inbox leaves.

  • W5.3 (b916fdbd) — archives replaced 4 layout types with a single core.archives-list-view leaf.

Sub 8 (#1475) subsequently restored the pre-W5.3 archives UI as a single-leaf provider (archives-list-view-provider.tsx), so those 4 layouts are alive again in the current codebase. However, the inbox timeline layouts were NOT restored and remain unavailable in the frameset codebase.

All git show main:<path> excerpts below are pinned to main at commit 3c653c46a4e3b9015829904023b527e9e9842.


Inbox layouts

Layout: inbox.timeline-vertical

Layout slug: inbox.timeline-vertical

What it rendered:

A virtualised vertical scroll area (Slack-style: oldest message at the top, newest anchored at the bottom). Each draft slot was displayed as a card showing the draft number, optional heading from frontmatter, created-at date, and either a 4-line clamped body preview or the full body text (user toggle). The active card embedded a live CodeMirror editor so the user could edit without leaving the timeline. Inactive cards were read-only previews that the user clicked to activate. A thin toolbar above the scroll area contained: a "New draft" icon button, a direction toggle (horizontal / vertical), a "Show full content" checkbox (vertical mode only), and a sort dropdown (created-at / updated-at, asc / desc). The layout bottom-anchored on mount (scroll to active card) and re-anchored on sort changes or showFullContent toggle. When no drafts were visible, a centered "No drafts to show" message was rendered.

Path + revision:

tauri-app/renderer/components/timeline-view/index.tsx at main (3c653c46)

Render function excerpt (verbatim, ≤80 lines — vertical body only):

// Lines 499-706 of tauri-app/renderer/components/timeline-view/index.tsx @ main
return (
  <div className="relative h-full overflow-hidden flex flex-col" data-testid="timeline-view">
    {/* Toolbar */}
    <div className="flex items-center gap-sm px-md py-xs border-b border-edge shrink-0">
      <Tooltip label="New draft" placement="bottom" align="center">
        <IconButton
          onClick={() => window.dispatchEvent(new CustomEvent(NEW_DRAFT_EVENT))}
          aria-label="New draft"
          data-testid="timeline-view-new-draft-button"
        >
          <PlusIcon size="sm" />
        </IconButton>
      </Tooltip>
      <div className="flex items-center gap-xs">
        <Tooltip label="Horizontal timeline layout" placement="bottom" align="center">
          <IconButton
            onClick={() => onDirectionChange?.("horizontal")}
            active={direction === "horizontal"}
            aria-label="Horizontal timeline layout"
            data-testid="timeline-view-direction-toggle-horizontal"
          ><LayoutLRIcon size="sm" /></IconButton>
        </Tooltip>
        <Tooltip label="Vertical timeline layout" placement="bottom" align="center">
          <IconButton
            onClick={() => onDirectionChange?.("vertical")}
            active={direction === "vertical"}
            aria-label="Vertical timeline layout"
            data-testid="timeline-view-direction-toggle-vertical"
          ><LayoutTBIcon size="sm" /></IconButton>
        </Tooltip>
      </div>
      {direction === "vertical" && (
        <label className="flex items-center gap-xs text-xs text-fg-muted cursor-pointer select-none"
          data-testid="timeline-view-show-full-content">
          <input type="checkbox" checked={showFullContent}
            onChange={(e) => onShowFullContentChange?.(e.target.checked)}
            className="accent-accent" />
          <span>Show full content</span>
        </label>
      )}
      <label className="flex items-center gap-xs text-xs text-fg-muted">
        <span>Sort</span>
        <select value={encodeTimelineSort(sort)}
          onChange={(e) => onSortChange?.(decodeTimelineSort(e.target.value))}
          className="bg-bg border border-edge rounded text-xs text-fg px-xs py-[2px] cursor-pointer"
          data-testid="timeline-view-sort-select">
          {TIMELINE_SORT_OPTIONS.map((opt) => (
            <option key={opt.value} value={opt.value}>{opt.label}</option>
          ))}
        </select>
      </label>
    </div>
    {/* Body — vertical branch */}
    <div className="relative flex-1 min-h-0 overflow-hidden">
      <div ref={scrollContainerRef} className="h-full overflow-y-auto"
        data-testid="timeline-view-vertical-scroll">
        <div style={{ height: `${verticalVirtualizer.getTotalSize()}px`, width: "100%", position: "relative" }}>
          {verticalVirtualizer.getVirtualItems().map((virtualItem) => {
            const n = visibleDraftNumbers[virtualItem.index];
            const isActive = n === localActiveDraft;
            return (
              <div key={n} ref={verticalVirtualizer.measureElement}
                data-index={virtualItem.index}
                style={{ position: "absolute", top: 0, left: 0, width: "100%",
                  transform: `translateY(${virtualItem.start}px)` }}>
                <TimelineCard
                  draftNumber={n} content={getContent(n)} isActive={isActive}
                  onActivate={handleActivate} direction="vertical"
                  showFullContent={showFullContent} cardWidth={cardWidth}
                  activeCardAttr={isActive ? ACTIVE_CARD_ATTR : undefined}
                  {...(isActive ? { editorContent, onEditorChange: setEditorContent } : {})}
                />
              </div>
            );
          })}
        </div>
      </div>
    </div>
  </div>
);

Top-level dependencies of TimelineView:

ImportSource
useVirtualizer@tanstack/react-virtual
IconButton, LayoutLRIcon, LayoutTBIcon, PlusIcon, Tooltip@takazudo/ui-components
getBackend@takazudo/backend-bridge
parseFrontmatter@takazudo/file-utils
defaultTimelineSort, encodeTimelineSort, decodeTimelineSort, TIMELINE_SORT_OPTIONS@takazudo/app-defaults
DRAFTS_CHANGED_EVENT, NEW_DRAFT_EVENT, SWITCH_DRAFT_EVENT../../data/app-commands
TimelineCard./timeline-card
TimelineSort (type)../../hooks/use-timeline-view-settings
sortDraftNumbers, DraftMeta (type)./sort

Per-dependency applicability verdicts:

DependencyVerdict
@tanstack/react-virtualstill-applicable-as-is — package is in the workspace, used by pile-view and grid-view today
@takazudo/ui-components (IconButton, icons, Tooltip)still-applicable-as-is — all referenced exports still exist
@takazudo/backend-bridge (getBackend)still-applicable-as-is — same API shape
@takazudo/file-utils (parseFrontmatter)still-applicable-as-is — function unchanged
@takazudo/app-defaults sort helpersstill-applicable-as-isdefaultTimelineSort, encodeTimelineSort, decodeTimelineSort, TIMELINE_SORT_OPTIONS are all still exported from @takazudo/app-defaults
DRAFTS_CHANGED_EVENT, NEW_DRAFT_EVENT, SWITCH_DRAFT_EVENTstill-applicable-as-is — all three event constants still in app-commands.ts
TimelineCard (./timeline-card)still-applicable-as-is — file exists at the same path in the current codebase; component was not removed
TimelineSort from use-timeline-view-settingsneeds-rewriteuse-timeline-view-settings.ts was deleted in the frameset rewrite (W7.2). The type is now exported from @takazudo/app-defaults directly; Sub 10 must re-create the hook or import the type from its new location
sortDraftNumbers, DraftMeta from ./sortstill-applicable-as-issort.ts still exists in components/timeline-view/sort.ts

User-facing control:

The layout was toggled via:

  1. Command palette entry "Timeline View" (id "timeline-view", dispatches TIMELINE_VIEW_EVENT).

  2. The same event could be bound to a keyboard shortcut via AppSettings.shortcuts.timelineView (no default shortcut was assigned).

The switch was a session-only boolean inboxViewMode: "editor" | "timeline" in WritePageContent state — entering timeline mode silently collapsed any active split-editor, and returning to editor mode restored a clean single-pane state. The user-visible toggle was purely event-driven (no persistent toolbar button inside the inbox UI itself).

Sub-task pointer: Restore in Sub 10 (#1495)

Risk notes: The biggest failure mode is binding TimelineView directly inside InboxProvider without understanding the mutable-ref / draft-change plumbing. In the original code, TimelineView required a onDraftChange prop (the canonical useDraftManager.handleDraftChange) rather than relying on dispatching SWITCH_DRAFT_EVENT, because <EditorExperience> and its SWITCH_DRAFT_EVENT listener are unmounted while timeline is active. If Sub 10 re-uses TimelineView inside the frameset leaf but wires it only to the event bus, clicking a card will update the visual active marker but leave the CodeMirror content stale (original issue #1183 R1.b). Sub 10 must thread the draft-change callback from the provider's draft-manager down into TimelineView.


Layout: inbox.timeline-horizontal

Layout slug: inbox.timeline-horizontal

What it rendered:

A virtualised horizontal card carousel. Cards were arranged left-to-right with the newest draft on the leftmost position and older drafts scrolling to the right (the list was reversed from the sorted order for this mode). Each card occupied an exact pixel width (user-adjustable via a "Size" range slider in the toolbar, range 400–800 px, default 640 px). The active card embedded the live CodeMirror editor; inactive cards showed a body preview with whitespace-pre-wrap. The same top-level toolbar as vertical mode was shown, but with the "Size" slider replacing the "Show full content" checkbox (which was hidden in horizontal mode). A sort dropdown was always visible. On mount and on sort changes, the virtualizer scroll-to-index'd the active card to center. No "bottom anchor" logic — the initial scroll target was whichever index position the active draft occupied in the sorted+reversed list.

Path + revision:

tauri-app/renderer/components/timeline-view/index.tsx at main (3c653c46) — same file as vertical, direction === "horizontal" branch of the render.

Render function excerpt (verbatim — horizontal body only, ≤80 lines):

// Lines 598-648 of tauri-app/renderer/components/timeline-view/index.tsx @ main
{direction === "horizontal" && (
  <label className="flex items-center gap-sm text-xs text-fg-muted">
    <span>Size</span>
    <input type="range" min={MIN_CARD_WIDTH} max={MAX_CARD_WIDTH}
      value={cardWidth}
      onChange={(e) => onCardWidthChange?.(Number(e.target.value))}
      className="w-[160px] accent-accent"
      data-testid="timeline-view-card-width-slider" />
  </label>
)}

{/* Body — horizontal branch */}
<div ref={scrollContainerRef} className="h-full overflow-x-auto"
  data-testid="timeline-view-horizontal-scroll">
  <div style={{
    width: `${horizontalVirtualizer.getTotalSize()}px`,
    height: "100%",
    position: "relative",
  }}>
    {horizontalVirtualizer.getVirtualItems().map((virtualItem) => {
      const n = visibleDraftNumbers[virtualItem.index];
      const isActive = n === localActiveDraft;
      return (
        <div key={n}
          ref={horizontalVirtualizer.measureElement}
          data-index={virtualItem.index}
          style={{
            position: "absolute", top: 0, left: 0,
            height: "100%", width: `${cardWidth}px`,
            transform: `translateX(${virtualItem.start}px)`,
          }}>
          <TimelineCard
            draftNumber={n} content={getContent(n)} isActive={isActive}
            onActivate={handleActivate} direction="horizontal"
            showFullContent={showFullContent}
            activeCardAttr={isActive ? ACTIVE_CARD_ATTR : undefined}
            {...(isActive ? { editorContent, onEditorChange: setEditorContent } : {})}
          />
        </div>
      );
    })}
  </div>
</div>

Note on direction switching: When the user switched from vertical to horizontal, visibleDraftNumbers was reversed ([...sorted].reverse()) so that the newest draft (highest sort position) appeared at the leftmost position (index 0 of the virtualizer). The horizontal virtualizer used estimateSize: () => cardWidthRef.current + 1 (card width + 1 px border). The horizontalAnchorDoneRef one-shot guard ensured the active card was scrolled into view on first mount in horizontal mode and after sort changes.

Top-level dependencies: Identical to inbox.timeline-vertical (same file, same imports — direction is a runtime prop, not a separate component).

Per-dependency applicability verdicts: Same as inbox.timeline-vertical above; no additional imports are needed for the horizontal branch.

User-facing control: Same as vertical — "Timeline View" command in the command palette (id "timeline-view"). Once in timeline mode the user switched between horizontal and vertical via the direction toggle buttons in the timeline toolbar (LayoutLRIcon = horizontal, LayoutTBIcon = vertical). The selected direction was persisted to AppSettings.layout.timelineView.direction via useTimelineViewSettings (now deleted; see needs-rewrite verdict above).

Sub-task pointer: Restore in Sub 10 (#1495)

Risk notes: The same onDraftChange prop risk as vertical applies. An additional risk specific to horizontal mode: the reversed draft list ([...sorted].reverse()) must be applied to visibleDraftNumbers BEFORE passing to the virtualizer, not after — getting this wrong produces correct card count but wrong draft-number-to-position mapping, causing the wrong card to appear active and scroll-into-view to land on the wrong position.

The cardWidth state in TimelineView was formerly persisted via useTimelineViewSettings (deleted hook). Sub 10 must either re-create the settings hook or find another persistence path — without it, the slider resets to 640 px on every mount.


Archives layouts

All four archives layouts were restored by Sub 8 (#1475) in tauri-app/renderer/view-providers/archives-list-view-provider.tsx. They are fully alive in the current codebase and the layout-switcher in the archives header still cycles between them. The sections below document the pre-W5.3 contract for completeness and supply dependency verdicts for any Sub that touches them.

Layout: archives.list-detail

Layout slug: archives.list-detail

What it rendered:

A classic two-pane layout. On desktop: a resizable left sidebar listing all messages (using Sidebar, a virtualised list of SidebarItem rows showing title and date) with an adjustable divider (PanelDivider), and a right detail pane (Detail) showing the full rendered preview of the selected message plus action buttons (Send to Inbox, Edit, Delete). The sidebar started at 320 px wide; the PanelDivider allowed drag-resize between 200 and 500 px. Clicking a row selected the message and revealed the detail pane. An "Enlarge" toggle button hid the sidebar to give the detail pane the full panel width. Pressing Escape deselected. On mobile: full-width list with a DetailBottomSheet that slid up from the bottom when an item was selected. A restoredFilename prop drove a brief fade-in animation on the restored item after undo.

Path + revision:

tauri-app/renderer/components/archives/list-detail-view.tsx at main (3c653c46)

Render function excerpt (verbatim, desktop branch, ≤80 lines):

// Lines 98-152 of list-detail-view.tsx @ main — desktop return
return (
  <div ref={containerRef} className="flex h-full">
    {!isEnlarged && (
      <>
        <div className="flex flex-col bg-bg-alt min-w-0"
          style={{
            flex: sidebarRatio != null
              ? `0 0 ${sidebarRatio}%`
              : `0 0 ${INITIAL_SIDEBAR_WIDTH}px`,
          }}>
          <Sidebar messages={messages} selectedFilename={selectedFilename}
            onSelect={onSelect} restoredFilename={restoredFilename} />
        </div>
        <PanelDivider direction="horizontal" onResize={setSidebarRatio}
          ratio={sidebarRatio ?? undefined}
          primaryMinSize={200} primaryMaxSize={500}
          secondaryMinSize={300}
          primarySnap={false} secondarySnap={false} />
      </>
    )}
    <div className="flex flex-col flex-1 min-w-0 overflow-hidden">
      <DetailPanelHeader isEnlarged={isEnlarged}
        onToggleEnlarge={() => setIsEnlarged((v) => !v)}
        onClose={onDeselect} />
      <div className="flex-1 min-h-0 overflow-hidden">
        <Detail message={selectedMessage} onSendToInbox={onSendToInbox}
          onEditSave={onEditSave} onDelete={onDelete} />
      </div>
    </div>
  </div>
);

Top-level dependencies:

ImportSource
PanelDivider, useIsMobileViewport@takazudo/ui-components
MessageMeta (type)../../types
Sidebar./sidebar
Detail./detail
DetailBottomSheet./detail-bottom-sheet
DetailPanelHeader./detail-panel-header

Per-dependency applicability verdicts:

DependencyVerdict
PanelDivider, useIsMobileViewportstill-applicable-as-is — both exported from @takazudo/ui-components unchanged
MessageMetastill-applicable-as-is — type re-exported from ../../types which re-exports from @takazudo/backend-bridge
Sidebarstill-applicable-as-isarchives/sidebar.tsx exists at same path
Detailstill-applicable-as-isarchives/detail.tsx exists at same path
DetailBottomSheetstill-applicable-as-isarchives/detail-bottom-sheet.tsx exists at same path
DetailPanelHeaderstill-applicable-as-isarchives/detail-panel-header.tsx exists at same path

User-facing control: Layout switcher icon buttons in the archives header toolbar (ArchivesHeaderLayoutSwitcher). The ListDetailViewIcon button selects this layout. The selected type is persisted in AppSettings.layout.archivesLayoutType via useArchivesLayoutType.

Sub-task pointer: Already restored — no action required by Sub 10 or Sub 11. If a future sub modifies this layout it should be Sub 11 (#1496).

Risk notes: The PanelDivider resize logic uses a percentage ratio seeded from an initial 320 px / container width calculation. If the container has no measured width on first render (hidden or zero-sized), sidebarRatio stays null and the sidebar falls back to the static 0 0 ${INITIAL_SIDEBAR_WIDTH}px flex value. A ResizeObserver retries until the container has a real width. Sub 11 should be careful not to break this fallback if it refactors the mounting sequence.


Layout: archives.pile

Layout slug: archives.pile

What it rendered:

A card-stack layout with two direction sub-modes shared by the same component:

  • Horizontal (default): A virtualised horizontal carousel of cards scrolling left-to-right (newest at left). Each card was a full-height button showing heading, date, and body text. A "Size" slider (200–400 px) controlled card width, persisted in localStorage["zudotext.pile-view.card-width"]. Clicking a card opened a sliding right panel showing the full Detail view. The panel was resizable via PanelDivider, had an enlarge toggle and close button. Desktop panel slid in from the right; mobile used a bottom sheet.

  • Vertical (Slack-style): Messages sorted oldest-at-top to newest-at-bottom via a reversed array. Each card was a full-width row with clamped (4-line) or full-content body text (user toggle). Same sliding panel as horizontal mode. Bottom-anchoring logic on mount, direction switch, and showFullContent toggle.

The toolbar above the scroll area contained a horizontal/vertical toggle (same LayoutLRIcon / LayoutTBIcon icons as the inbox timeline view) and mode-specific controls (Size slider in horizontal; Show-full-content checkbox in vertical).

Path + revision:

tauri-app/renderer/components/archives/pile-view.tsx at main (3c653c46)

Render function excerpt (verbatim, toolbar + horizontal card virtual list, ≤80 lines):

// Lines 363-461 of pile-view.tsx @ main — toolbar + horizontal render branch
return (
  <div className="relative h-full overflow-hidden flex flex-col" ref={containerRef}>
    {/* Toolbar */}
    <div className="flex items-center gap-sm px-md py-xs border-b border-edge shrink-0">
      <div className="flex items-center gap-xs">
        <Tooltip label="Horizontal pile layout" placement="bottom" align="center">
          <IconButton onClick={() => onDirectionChange?.("horizontal")}
            active={direction === "horizontal"} aria-pressed={direction === "horizontal"}
            aria-label="Horizontal pile layout"
            data-testid="pile-view-direction-toggle-horizontal">
            <LayoutLRIcon size="sm" />
          </IconButton>
        </Tooltip>
        <Tooltip label="Vertical pile layout" placement="bottom" align="center">
          <IconButton onClick={() => onDirectionChange?.("vertical")}
            active={direction === "vertical"} aria-pressed={direction === "vertical"}
            aria-label="Vertical pile layout"
            data-testid="pile-view-direction-toggle-vertical">
            <LayoutTBIcon size="sm" />
          </IconButton>
        </Tooltip>
      </div>
      {direction === "horizontal" && (
        <label className="flex items-center gap-sm text-xs text-fg-muted">
          <span>Size</span>
          <input type="range" min={MIN_CARD_WIDTH} max={MAX_CARD_WIDTH}
            value={cardWidth}
            onChange={(e) => handleCardWidthChange(Number(e.target.value))}
            className="w-[80px] accent-accent" />
        </label>
      )}
      {direction === "vertical" && (
        <label className="flex items-center gap-xs text-xs text-fg-muted cursor-pointer select-none"
          data-testid="pile-view-show-full-content">
          <input type="checkbox" checked={showFullContent}
            onChange={(e) => onShowFullContentChange?.(e.target.checked)}
            className="accent-accent" />
          <span>Show full content</span>
        </label>
      )}
    </div>

    {/* Horizontal card scroll */}
    <div ref={scrollRef} className="h-full overflow-x-auto"
      data-testid="pile-view-horizontal-scroll">
      <div style={{ width: `${horizontalVirtualizer.getTotalSize()}px`,
        height: "100%", position: "relative" }}>
        {horizontalVirtualizer.getVirtualItems().map((virtualItem) => {
          const msg = messages[virtualItem.index];
          return (
            <button key={msg.filename}
              style={{ position: "absolute", top: 0, left: 0,
                height: "100%", width: `${cardWidth}px`,
                transform: `translateX(${virtualItem.start}px)` }}
              className={`border-r border-edge p-lg flex flex-col text-left cursor-pointer
                bg-transparent hover:bg-hover transition-colors
                ${msg.filename === selectedFilename ? "bg-accent-subtle" : ""}`}
              onClick={() => onSelect(msg.filename)}>
              {msg.heading && (
                <div className="text-sm font-semibold text-fg truncate mb-xs">
                  {msg.heading}
                </div>
              )}
              <div className="text-xs text-fg-muted mb-md">
                {formatDate(getCreatedAt(msg))}
              </div>
              <div className="text-xs text-fg-muted flex-1 overflow-y-auto min-h-0
                opacity-70 whitespace-pre-wrap break-words">
                {bodyMap?.get(msg.filename) || msg.snippet || ""}
              </div>
            </button>
          );
        })}
      </div>
    </div>
  </div>
);

Top-level dependencies:

ImportSource
useVirtualizer@tanstack/react-virtual
MessageMeta (type)../../types
formatDate../../utils/format-date
getCreatedAt../../utils/metadata-accessor
Detail./detail
IconButton, XMarkIcon, ArrowsPointingOutIcon, ArrowsPointingInIcon, LayoutLRIcon, LayoutTBIcon, PanelDivider, useIsMobileViewport, Tooltip@takazudo/ui-components

Per-dependency applicability verdicts:

DependencyVerdict
@tanstack/react-virtualstill-applicable-as-is
MessageMetastill-applicable-as-is
formatDate from ../../utils/format-datestill-applicable-as-is — file exists at same path
getCreatedAt from ../../utils/metadata-accessorstill-applicable-as-is — file exists at same path
Detailstill-applicable-as-is
All @takazudo/ui-components exportsstill-applicable-as-is — all referenced icons and components still exported

User-facing control: PileViewIcon button in the LayoutSwitcher. Direction and showFullContent are toggled via the in-toolbar buttons, and also via command palette entries "Toggle Pile View Direction" (toggle-pile-view-direction, dispatches TOGGLE_PILE_VIEW_DIRECTION_EVENT) and "Toggle Pile View Full Content" (toggle-pile-view-full-content, dispatches TOGGLE_PILE_VIEW_FULL_CONTENT_EVENT). Both events are still defined in app-commands.ts and have command palette entries.

Sub-task pointer: Already restored — no action required by Sub 10 or Sub 11.

Risk notes: The PanelDivider is used inside the pile-view body div with an absolute position strategy. If Sub 11 changes the panel layout, the containerWidth ResizeObserver must stay wired or handlePanelResize will compute incorrect widths. The bodyMap prop (full message bodies pre-loaded by archives-page/archives-list-view-provider) must be passed to PileView or horizontal card body text will fall back to the snippet field (shorter).


Layout: archives.grid

Layout slug: archives.grid

What it rendered:

A responsive tile grid of archive cards. The grid computed the number of columns dynamically from the container width and a MIN_CARD_WIDTH of 280 px with a 12 px gap. Cards were fixed-height (200 px) rectangular tiles showing heading, date, and a truncated snippet. A virtualiser rendered rows of cards (one VirtualItem = one row of N cards). Clicking a card opened a sliding right panel (480 px wide on desktop, fixed) showing the full Detail pane. On mobile a DetailBottomSheet was used instead. The panel had an enlarge toggle. No card width slider — width was purely responsive.

Path + revision:

tauri-app/renderer/components/archives/grid-view.tsx at main (3c653c46)

Render function excerpt (verbatim — grid card render, ≤80 lines):

// Lines 96-175 of grid-view.tsx @ main — virtual grid render
const grid = (
  <div ref={scrollRef} className="flex-1 overflow-y-auto">
    {messages.length === 0 ? (
      <div className="flex items-center justify-center h-full text-fg-muted text-sm">
        No messages found
      </div>
    ) : (
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const startIdx = virtualRow.index * columnsPerRow;
          const rowMessages = messages.slice(startIdx, startIdx + columnsPerRow);
          return (
            <div key={virtualRow.index}
              style={{
                position: "absolute", top: 0,
                left: `${PADDING}px`, right: `${PADDING}px`,
                height: `${CARD_HEIGHT}px`,
                transform: `translateY(${virtualRow.start}px)`,
                display: "grid",
                gridTemplateColumns: `repeat(${columnsPerRow}, 1fr)`,
                gap: `${GAP}px`,
              }}>
              {rowMessages.map((msg) => {
                const isSelected = msg.filename === selectedFilename;
                const isPending = pendingFilenames.has(msg.filename);
                return (
                  <button key={msg.filename}
                    className={`relative overflow-hidden rounded-lg border p-lg
                      flex flex-col text-left transition-colors
                      ${isPending ? "opacity-50 pointer-events-none cursor-default" : "cursor-pointer"}
                      ${isSelected
                        ? "border-accent bg-active text-active-fg"
                        : "border-edge bg-surface hover:bg-hover"}`}
                    onClick={() => onSelect(msg.filename)}
                    aria-disabled={isPending || undefined}
                    aria-busy={isPending || undefined}>
                    {msg.heading && (
                      <div className="text-sm font-semibold text-fg truncate mb-xs">
                        {msg.heading}
                      </div>
                    )}
                    <div className="text-xs text-fg-muted mb-md">
                      {formatDate(getCreatedAt(msg))}
                    </div>
                    <div className="text-xs text-fg-muted opacity-70 flex-1 overflow-hidden">
                      {isPending ? "Generating AI title..." : (msg.snippet || "")}
                    </div>
                    <div className="absolute bottom-0 left-0 right-0 h-[40px] pointer-events-none"
                      style={{
                        background: `linear-gradient(to top, var(${
                          isSelected ? "--color-active" : "--color-surface"
                        }), transparent)`,
                      }} />
                  </button>
                );
              })}
            </div>
          );
        })}
      </div>
    )}
  </div>
);

Top-level dependencies:

ImportSource
useVirtualizer@tanstack/react-virtual
MessageMeta (type)../../types
formatDate../../utils/format-date
getCreatedAt../../utils/metadata-accessor
usePendingRenames../../pending-renames-context
Detail./detail
DetailBottomSheet./detail-bottom-sheet
DetailPanelHeader./detail-panel-header
useIsMobileViewport@takazudo/ui-components

Per-dependency applicability verdicts:

DependencyVerdict
@tanstack/react-virtualstill-applicable-as-is
MessageMetastill-applicable-as-is
formatDate, getCreatedAtstill-applicable-as-is
usePendingRenames from ../../pending-renames-contextstill-applicable-as-is — context file still exists at same path
Detail, DetailBottomSheet, DetailPanelHeaderstill-applicable-as-is
useIsMobileViewportstill-applicable-as-is

User-facing control: GridViewIcon button in the LayoutSwitcher.

Sub-task pointer: Already restored — no action required by Sub 10 or Sub 11.

Risk notes: columnsPerRow depends on a containerWidth state seeded by a ResizeObserver. While containerWidth is 0, effectiveWidth falls back to 800 px (hard-coded fallback), which can produce a different column count than the real container width, causing a brief layout reflow after first render. Sub 11 should not remove the effectiveWidth fallback or the initial paint will be a single-column grid regardless of screen width.


Layout: archives.table

Layout slug: archives.table

What it rendered:

A sortable, selectable, virtualised HTML table. Columns: a checkbox column (bulk selection), Content (heading or filename, sortable), Created (date, sortable), Updated (date, sortable), Snippet. The table header was sticky. Clicking a column header sorted by that field (toggling asc/desc if already sorted by that field). A bulk-action bar appeared above the table when rows were checked, offering "Delete" and "Move to Inbox" buttons. Clicking a row opened a sliding right panel (480 px wide, enlarge-toggle, Escape-to-close) showing the Detail pane with an enter/exit CSS transition (translateX + opacity). On mobile, a DetailBottomSheet was used. The Detail was kept mounted (not toggled) to preserve the in-flight edit state.

Path + revision:

tauri-app/renderer/components/archives/table-view.tsx at main (3c653c46)

Render function excerpt (verbatim — table head + virtualised rows, ≤80 lines):

// Lines 196-285 of table-view.tsx @ main — table head + tbody
<div ref={scrollRef} className="flex-1 overflow-auto"
  style={!isMobile && panelOpen && !isEnlarged ? { paddingRight: 480 } : undefined}>
  <table className="w-full border-collapse text-sm">
    <thead className="sticky top-0 bg-bg-alt">
      <tr className="border-b border-edge">
        <th className="w-[40px] p-md text-center">
          <input type="checkbox" checked={allChecked} onChange={toggleAll} />
        </th>
        <th className="text-left p-md cursor-pointer hover:bg-hover select-none"
          onClick={() => handleSort("content")}>
          Content{sortIndicator("content")}
        </th>
        <th className="text-left p-md w-[120px] cursor-pointer hover:bg-hover select-none"
          onClick={() => handleSort("createdAt")}>
          Created{sortIndicator("createdAt")}
        </th>
        <th className="text-left p-md w-[120px] cursor-pointer hover:bg-hover select-none"
          onClick={() => handleSort("updatedAt")}>
          Updated{sortIndicator("updatedAt")}
        </th>
        <th className="text-left p-md">Snippet</th>
      </tr>
    </thead>
    <tbody>
      {paddingTop > 0 && (
        <tr aria-hidden="true"><td style={{ height: paddingTop }} colSpan={5} /></tr>
      )}
      {virtualRows.map((virtualRow) => {
        const msg = sortedMessages[virtualRow.index];
        const isPending = pendingFilenames.has(msg.filename);
        const isSelected = msg.filename === selectedFilename;
        return (
          <tr key={msg.filename}
            className={`border-b border-edge transition-colors
              ${isPending ? "opacity-50 pointer-events-none" : "cursor-pointer"}
              ${isSelected ? "bg-active text-active-fg" :
                isPending ? "" : "hover:bg-hover"}`}
            onClick={() => !isPending && onSelect(msg.filename)}
            aria-disabled={isPending || undefined} aria-busy={isPending || undefined}>
            <td className="p-md text-center" onClick={(e) => e.stopPropagation()}>
              <input type="checkbox" checked={checkedFilenames.has(msg.filename)}
                onChange={() => toggleCheck(msg.filename)} disabled={isPending} />
            </td>
            <td className={`p-md font-medium truncate max-w-[300px]
              ${isSelected ? "" : "text-fg"}`}>
              {isPending ? "Generating AI title..." : (msg.heading || "")}
            </td>
            <td className={`p-md ${isSelected ? "" : "text-fg-muted"}`}>
              {formatDate(getCreatedAt(msg))}
            </td>
            <td className={`p-md ${isSelected ? "" : "text-fg-muted"}`}>
              {formatDate(getUpdatedAt(msg))}
            </td>
            <td className={`p-md truncate max-w-[200px] opacity-70
              ${isSelected ? "" : "text-fg-muted"}`}>
              {msg.snippet || ""}
            </td>
          </tr>
        );
      })}
      {paddingBottom > 0 && (
        <tr aria-hidden="true"><td style={{ height: paddingBottom }} colSpan={5} /></tr>
      )}
    </tbody>
  </table>
</div>

Top-level dependencies:

ImportSource
useVirtualizer@tanstack/react-virtual
MessageMeta (type)../../types
formatDate../../utils/format-date
getCreatedAt, getUpdatedAt../../utils/metadata-accessor
usePendingRenames../../pending-renames-context
Detail./detail
DetailBottomSheet./detail-bottom-sheet
Button, TrashIcon, InboxReturnIcon, IconButton, XMarkIcon, useIsMobileViewport@takazudo/ui-components
DetailPanelHeader./detail-panel-header

Per-dependency applicability verdicts:

DependencyVerdict
@tanstack/react-virtualstill-applicable-as-is
MessageMetastill-applicable-as-is
formatDate, getCreatedAt, getUpdatedAtstill-applicable-as-is
usePendingRenamesstill-applicable-as-is
Detail, DetailBottomSheet, DetailPanelHeaderstill-applicable-as-is
Button, TrashIcon, InboxReturnIcon, IconButton, XMarkIcon, useIsMobileViewportstill-applicable-as-is — all still exported from @takazudo/ui-components

User-facing control: TableViewIcon button in the LayoutSwitcher. Bulk actions (Delete / Move to Inbox) appear inline in the action bar above the table when rows are checked. These actions require two extra props on TableView that are not on the other layout components: deleteMessage (raw message-list delete function) and handleSendToInbox (by-filename send-to-inbox). These props must be passed from the provider; failing to wire them silently disables bulk actions.

Sub-task pointer: Already restored — no action required by Sub 10 or Sub 11.

Risk notes: The paddingRight: 480 style applied to the scroll container when the panel is open prevents the table from scrolling under the panel on desktop. If Sub 11 changes the panel width it must also update this magic number, or rows will scroll under the panel and become unreachable. The two-frame requestAnimationFrame trick in the panelState entering→visible transition is deliberate: a single rAF is not enough to guarantee the CSS transition fires because React may batch the two state updates into one paint.


Persistence schema (AppSettings.layout on main)

The following AppSettings.layout fields backed these layouts. Sub 10 / Sub 11 must ensure they are preserved or replaced when restoring the timeline view.

// From packages/app-defaults/src/types.ts @ main
layout?: {
  archivesLayoutType?: "pile" | "grid" | "list-detail" | "table";
  pileView?: {
    direction?: "horizontal" | "vertical";
    showFullContent?: boolean;
  };
  timelineView?: {
    direction?: "horizontal" | "vertical";
    showFullContent?: boolean;
    cardWidth?: number;
    sort?: TimelineSort;
  };
};

archivesLayoutType and pileView are already persisted by the restored archives-list-view-provider.tsx. timelineView has no current consumer because useTimelineViewSettings was deleted with the frameset rewrite.

Sub 10 must either re-create use-timeline-view-settings.ts or inline the persistence logic inside the inbox provider. The settings key path (layout.timelineView.*) is unchanged and the AppSettings type still contains the timelineView sub-object, so settings saved by the old code will still deserialise correctly.