loopable / web /src /home /HomePage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
2bdff0c verified
Raw
History Blame Contribute Delete
29.8 kB
// ---------------------------------------------------------------------------
// home/HomePage.tsx β€” WAVE 23 item 9 (ruling R7, contract C10): the landing.
//
// The layout is `reference/Airtable Home.png`, in OUR tokens: an `h1`, a row of
// quick-start cards, then the recently-opened databases under Today / Past 7
// days / Older, with a list/grid toggle. Nothing is copied but the ANATOMY β€”
// every colour, size and weight is the Loopable palette (R1's rule for the
// builder, applied here for the same reason).
//
// β›” THIS PAGE IS CHROME, NOT A GRANTED SURFACE, and the distinction is the one
// the shell's oldest law turns on (`Shell.tsx` :1039 β€” "the nav is
// server-filtered and an undeclared surface is denied"). Home does not violate
// that law because it cannot: every database it names is resolved from the
// `entries` the server returned (`homeModel.groupRecents` drops a recent whose
// key is not in them), and the four cards open doors the rail already offered.
// There is no payload here that `GET /nav` did not send.
//
// It holds NO state of its own beyond the layout toggle. The recents come down
// with the nav; the four actions are the frame's, because the frame owns the
// dialogs and the hash.
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { FolderMark } from "../customer-grid/icons";
// ⭐ WAVE 26 Β· ITEM 18 / R14 + C7 β€” THE LOOP MARK ON HOME'S FOURTH `.auto-dot` SITE.
// The scout table named three call sites; this is the fourth, found by SESSION B and handed over
// rather than reached across the fence. Same rule as the rail: consume the component that points
// at the generated artifact, never inline the path ([[loopable-nav-logo-toggle]] β€” one element
// paints the mark).
import { AGENTS_MODULE_LABEL } from "../inbox/inboxModel";
import { Mark } from "../shell/Brand";
import { dbChipClass } from "../shell/nav";
import type { DatabaseEntry, Recent } from "../shell/nav";
// ⭐ WAVE 35 Β· W35-T15 (R4, C2) β€” ONE STAR CONTROL, drawn in the module that owns the concept and
// worn by both surfaces. R4 makes star and "mark important" one idea and one flag; a second star
// component here would be the "one mark, one meaning" failure DESIGN.md 4 names, and `useStarred`
// is what keeps Home and Starred from ending up with different ideas of what is starred.
import { StarButton, Tile } from "../starred/StarredPage";
import { shapeStarred, useStarred, viewCount } from "../starred/starredApi";
import type { StarKind, Starred, StarredCounts, StarredLoad } from "../starred/starredApi";
import {
HOME_LAYOUT_KEY,
allDatabases,
parseLayout,
} from "./homeModel";
import type { AutomationTile, HomeLayout } from "./homeModel";
// ── the four quick-start marks ───────────────────────────────────────────────────────────────
//
// Drawn here, in the same 16x16 stroke vocabulary as the rail's `DbIcon`/`AutoIcon`/`BellIcon`
// β€” the established pattern in this tree (the shell and the alerts pane each draw their own
// bell rather than sharing an icon module). DESIGN.md's rule is "extend the set, don't import a
// new icon language", and these are the same language: 16x16, `currentColor` strokes, no fills,
// no emoji.
/** Templates β€” stacked sheets, the thing a template gives you. */
function TemplateIcon() {
return (
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2.2" y="4.4" width="11.6" height="9.2" rx="1.4" />
<path d="M4.4 4.4V2.6h9.4v9.2h-1.8M2.2 7.6h11.6" />
</svg>
);
}
/** A blank database β€” the rail's cylinder with the plus that makes one. */
function NewDbIcon() {
return (
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
<ellipse cx="6.6" cy="3.7" rx="4.4" ry="1.8" />
<path d="M2.2 3.7v6.6c0 1 2 1.8 4.4 1.8" />
<path d="M11 3.7v3.1" />
<path d="M11.6 10.4v4M9.6 12.4h4" />
</svg>
);
}
/*
* ⭐ WAVE 26 Β· ITEM 18 / R14 β€” `AutomationIcon` STOOD HERE AND IS DELETED WITH THE DOT.
*
* It drew two nodes and the edge between them. R14 replaces it with the Loopable loop mark on
* the grounds that *"these automations are basically loops"* β€” so the tile's chip now carries the
* same mark the rail does, and this hand-inlined path had no second caller left. (It had already
* survived one deletion: wave 25 R8 removed the "Automated database" card and KEPT this because
* the tiles still used it. The tiles no longer do.)
*
* β›” NOT REPLACED BY A NEW SVG. C7 is explicit β€” consume the existing component. The mark's one
* source of truth is generated, and `shell/Brand.tsx`'s header records that a hand-redrawn copy
* silently painted LAST WAVE'S BRAND while a comment claimed parity.
*/
/**
* Connect a source β€” a two-prong plug with a cord, which is the thing you put in an outlet.
*
* ⭐ WAVE 35 · W35-T15 (owner item 5): *"change the Icon for the connector to look like the cable
* head that you would put in an electrical outlet."* It drew two links of a CHAIN until now, and
* so does its twin on the rail (`shell/Shell.tsx::PlugIcon`, whose NAME has been wrong since wave
* 23). The prongs are the load-bearing part of the drawing; the cord is what stops it reading as
* a padlock at 16px.
*
* β›” THE TWO COPIES MUST NOT DIVERGE and they are in two different fences, so the geometry below
* was published in mailbox B-5 as an ASK to the session that owns the rail (W35-T04) rather than
* changed here and hoped about. One glyph, one meaning; two plugs that disagree is worse than the
* chain, because at least the chain was wrong in both places identically.
*/
function ConnectIcon() {
return (
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6.1 5.2V2.1M9.9 5.2V2.1" />
<path d="M12.1 5.2v3.1a2.7 2.7 0 0 1-2.7 2.7H6.6a2.7 2.7 0 0 1-2.7-2.7V5.2Z" />
<path d="M8 11v1.6a1.6 1.6 0 0 0 1.6 1.6h1.5" />
</svg>
);
}
/** The two layout marks: rows, and a 2x2 of tiles. The reference pair, in our stroke weight. */
function ListIcon() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
<path d="M2.6 4.4h10.8M2.6 8h10.8M2.6 11.6h10.8" />
</svg>
);
}
function GridIcon() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
<rect x="2.5" y="2.5" width="4.6" height="4.6" rx="1" />
<rect x="8.9" y="2.5" width="4.6" height="4.6" rx="1" />
<rect x="2.5" y="8.9" width="4.6" height="4.6" rx="1" />
<rect x="8.9" y="8.9" width="4.6" height="4.6" rx="1" />
</svg>
);
}
/**
* One quick-start card. A `<button>` β€” every one of these opens a dialog or a wizard rather
* than navigating, and a link that does not link is a lie the browser's status bar tells.
*
* ⚠ `type="button"` and the UA reset ride the CLASS (DESIGN.md 4's button rule: a styled
* `<button>` sets `border/background/font/color` in its base rule, or it leaks the user agent's
* chrome β€” the wave-19 item-3 "mystery outer lines and tinted fill").
*/
function QuickCard({
icon,
title,
detail,
onClick,
}: {
icon: React.ReactNode;
title: string;
/** ONE line. R13: the app does not narrate itself, and this is a passing-through surface. */
detail: string;
onClick: () => void;
}) {
return (
<button type="button" className="home-card" onClick={onClick}>
<span className="home-card-head">
{icon}
<span className="home-card-title">{title}</span>
</span>
<span className="home-card-detail">{detail}</span>
</button>
);
}
/** What the FRAME hands Home. Unchanged by the wave-35 split, and named so both halves take
* exactly the same shape rather than drifting. */
export interface HomeProps {
/**
* The SHAPED, server-filtered nav. The only thing a tile may be drawn from.
*
* ⭐ WAVE 25 (D-54) β€” `DatabaseEntry[]`, NOT `NavEntry[]`, and the narrower type IS the fix.
* This page draws a "Databases" section, so it is entitled to the DATABASE list; the raw nav
* also carries the Automation SURFACE, which this page drew a tile for until the wave-24 visual
* pass caught it by eye. A brand only `databaseEntries()` can produce turns "somebody passed
* the wrong list" from a defect a human has to spot into one `npx tsc -b` refuses to build.
*/
entries: DatabaseEntry[];
recents: Recent[];
/**
* ⭐ WAVE 24 item 13 (R10, wiring W24-W3) β€” every automation this account may open.
*
* A REQUIRED prop, never optional: an optional one degrades to "the section does not exist",
* which is indistinguishable from "it was never built" and is precisely the wave-20 failure
* this repo shipped four times. `[]` is a legitimate value and renders no section; ABSENT is
* not a state the type allows.
*
* ⚠ An account without the automation grant gets a 403 from `/automations`, and the FRAME
* turns that into `[]` β€” the section then simply does not render, which is the correct answer
* rather than an error card about a surface they may not have.
*/
automations: AutomationTile[];
onTemplates: () => void;
onNewDatabase: () => void;
/* β›” `onAutomated` LEFT WITH THE CARD IT OPENED (wave 25 item 5a, R8). */
onConnectors: () => void;
/** Open one automation: the frame owns the hash, the surface owns which one is selected. */
onOpenAutomation: (id: string) => void;
/**
* ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” open a starred VIEW inside its database.
*
* β›” REQUIRED, and the frame's, for the same two reasons `onOpenAutomation` is. Optional would
* degrade to "clicking a starred view does nothing", indistinguishable from the section never
* having been built and red in no gate. And the act is a hash change PLUS a request to the grid
* for which view is selected: the grid's listener DROPS a viewId it has not loaded yet with no
* acknowledgement, so the emit needs the frame's bounded retry ladder against a MEASURED worst
* case, not a single hopeful dispatch.
* ⚠ `Shell.tsx` passes the IDENTICAL handler to Home and to Starred (mailbox A, `DONE B-8`), so
* a view opened from one cannot behave differently from the same view opened from the other.
*/
onOpenView: (database: string, viewId: string) => void;
}
/**
* ⭐ WAVE 35 Β· W35-T15 β€” THE PURE HALF, AND IT EXISTS SO HOME CAN BE LOOKED AT.
*
* Home held no state that could not be rendered until the star list arrived. `renderToStaticMarkup`
* never runs an effect, so a static render of the fetching component can only ever photograph the
* state where the stars are still unknown, i.e. the one state with no star in it. Splitting the
* read off is what makes "hover reveals an outline star, a starred tile shows a filled one" a thing
* a person can SEE without a server. `inbox/_inbox_shot.tsx` paid for this lesson first; the same
* split is now on `StarredSurface`.
*
* ⚠ THE DEFAULT EXPORT'S PROPS ARE UNCHANGED, so the frame's mount is untouched by this.
*/
export function HomeSurface({
entries,
recents,
automations,
onTemplates,
onNewDatabase,
onConnectors,
onOpenAutomation,
onOpenView,
starLoad,
starred,
busy,
counts,
toggle,
}: HomeProps & {
starLoad: StarredLoad;
starred: Starred;
busy: boolean;
/** C3's row counts, or `null` while the second read is in flight (R7: after paint, never on nav). */
counts: StarredCounts | null;
toggle: (kind: StarKind, id: string, on: boolean, database?: string) => void;
}) {
const [layout, setLayout] = useState<HomeLayout>(() => {
try {
return parseLayout(localStorage.getItem(HOME_LAYOUT_KEY));
} catch {
return "grid";
}
});
useEffect(() => {
try {
localStorage.setItem(HOME_LAYOUT_KEY, layout);
} catch {
// storage can be blocked; the toggle still works for this session
}
}, [layout]);
// ⚠ THE CLOCK IS READ ONCE, HERE, AND HANDED IN AS A NUMBER. `homeModel` stays pure so a gate
// can assert the ordering rather than whatever today happens to be.
// ⭐ WAVE 24 item 13 (R10) β€” `groupRecents`' Today / Past 7 days / Older buckets are GONE, and
// with them the reason a database you had never opened was invisible here. `allDatabases` draws
// every entry the server granted and uses the recents stamps only to ORDER them. The local
// midnight this used to compute has no remaining reader, so it is not computed.
const now = Math.floor(Date.now() / 1000);
const databases = allDatabases(entries, recents, now);
// β›” THE STAR CONTROL IS ABSENT UNTIL THE LIST ARRIVES, deliberately. Drawing an empty star
// before the list is known would STATE that nothing is starred and then flip under the reader
// once it lands. An affordance that appears a moment later is a smaller cost than a control
// that was wrong for a moment, and this one is revealed on hover anyway.
const known = starLoad.phase === "ready";
const dbStarred = new Set(starred.databases);
const agentStarred = new Set(starred.agents);
// ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” the starred VIEWS, resolved by the SAME function
// Starred uses. Item 9 asks for these views on Home and under Starred; running them through one
// resolver is what stops the two lists disagreeing about which views exist or what they are
// called, which is item 11's complaint applied before it can happen.
// β›” AND THE DROP RULE COMES WITH IT: `shapeStarred` refuses a view whose database is not in
// `entries`, so a star that outlived its database (deleted, or a grant revoked) draws NOTHING
// rather than a tile that lands the reader in a 403.
const starredViews = shapeStarred(starred, entries, automations).views;
return (
<div className="shell-home">
<h1 className="home-title">Home</h1>
<div className="home-cards">
{/* ⚠ THE DETAIL LINES ARE BYTE-IDENTICAL TO THE DIALOG'S ROWS, and that is deliberate
rather than lazy. Each of these cards opens the same door as a row in the
New-database dialog, and the first draft described the same two things in four
slightly different sentences ("fills and keeps up to date" vs "creates and keeps up
to date"; "ready-made views" vs "a curated set of views"). Two words for one thing is
how a reader concludes they are two things β€” DESIGN.md 1's "variety is a defect".
The TITLES do differ, and that is a register difference the reference makes too: a
card is an invitation ("Start with templates"), a menu row is a choice ("From a
template"). */}
{/* ⭐ WAVE 24 item 14 β€” this card now opens an UNDER-CONSTRUCTION note, not the picker.
⚠ THE CARD ONLY, AND THE ASYMMETRY IS DELIBERATE (see the wave doc's C-HOME note):
ruling R9 KEEPS "From a template" working inside the Database flyout's create menu, and
item 14 names this card by its exact title. Both instructions are followed literally,
which does mean one template door is boarded while another works. Written down so the
next session does not "fix" it in either direction β€” if it reads wrong live, it goes
back to the owner, not to a session's judgement.
The detail line is unchanged: it still describes what the feature WILL do, and the note
behind the click is what says it is not ready. A card that described its own
unavailability would be the tour DESIGN.md 4 forbids. */}
<QuickCard
icon={<TemplateIcon />}
title="Start with templates"
detail="Add a curated set of views to a database you already have."
onClick={onTemplates}
/>
<QuickCard
icon={<NewDbIcon />}
title="New database"
detail="A blank database in this workspace."
onClick={onNewDatabase}
/>
{/* β›” THE "AUTOMATED DATABASE" CARD STOOD HERE AND IS DELETED (wave 25 item 5a, R8).
It promised one act that was really two β€” make a database, and point an automation at
it β€” and the card could only ever do the second, because it handed off to the
automation surface and left the user to name a database there. R8: "creating a
database is one act; pointing an automation at it is another." `New database` above is
the first; the automation surface's own `New automation` is the second.
⚠ THREE CARDS NOW, and that is the whole visual delta on this row. The grid is
`auto-fill`/`minmax` so it re-flows rather than leaving a hole β€” verified in the
static render, not assumed. */}
<QuickCard
icon={<ConnectIcon />}
title="Connect a source"
detail="Browse the connectors this workspace can use."
onClick={onConnectors}
/>
</div>
<div className="home-recents-bar">
{/* The reference puts an "Opened anytime" filter here. We do not have one and do not
pretend to: a control that filters nothing is worse than no control. The toggle is
the real affordance, so it sits alone. */}
<div className="home-layout-toggle" role="group" aria-label="Layout">
<button
type="button"
className={"home-layout-btn" + (layout === "list" ? " is-on" : "")}
aria-pressed={layout === "list"}
aria-label="List"
title="List"
onClick={() => setLayout("list")}
>
<ListIcon />
</button>
<button
type="button"
className={"home-layout-btn" + (layout === "grid" ? " is-on" : "")}
aria-pressed={layout === "grid"}
aria-label="Grid"
title="Grid"
onClick={() => setLayout("grid")}
>
<GridIcon />
</button>
</div>
</div>
{databases.length === 0 ? (
// β›” ONE LINE, AND IT NAMES THE DOOR THAT FILLS IT (DESIGN.md 4 / R13). This is also
// where wave 18's tenant hero went: a workspace with no databases lands here, and the
// cards above ARE the "create your first database" affordance the hero carried. Two
// welcome messages on one screen is the bug Shell.tsx's empty-nav note already documents.
// ⭐ WAVE 24: the second sentence is GONE with the recency buckets. "Nothing opened yet"
// described a state that can no longer exist β€” every granted database is drawn whether it
// has been opened or not, so an empty list now means exactly one thing.
<p className="home-empty">
This workspace has no databases yet β€” create one above, or connect a source.
</p>
) : (
[{ id: "databases", title: "Databases", tiles: databases }].map((section) => (
<section key={section.id} className="home-section">
<h2 className="home-section-title">{section.title}</h2>
<div className={"home-tiles is-" + layout}>
{section.tiles.map((tile) => (
// ⚠ THE WRAPPER IS ALWAYS PRESENT, star or no star, so the grid cell is the same
// box in both states and the page does not re-lay-itself out when the star list
// lands. `.st-tilewrap` is a NEW rule in `starred/starred.css` scoped to itself;
// `.home-tile`'s own rule in `index.css` is untouched (that file is A's).
<div key={tile.key} className="st-tilewrap">
<a
className="home-tile"
href={tile.href}
{...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
>
{/* The SAME chip family as the database header and the rail row
(`dbChipClass` β†’ `.shell-db-chip` + its tone), so a database looks like
itself everywhere. DESIGN.md 4: new elements join the existing rhythm,
never a new control shape. The chosen mark wins when there is one; two
letters otherwise, which is what the reference tiles show.
β›” AND THOSE TWO BRANCHES CARRY A CONTRAST RULE β€” DO NOT "SIMPLIFY" THEM
APART. `dbChipClass` returns a TONE class only when there IS an icon, and
an icon is exactly when we draw a GLYPH. That matters because the tones are
measured for a graphical object, not for text: white on `--lp-blue-deep` is
3.29:1 (the stylesheet publishes every pairing beside its declaration) β€”
over the 3:1 bar a 16px glyph must clear, UNDER the 4.5:1 bar 12px letters
must. The fallback branch has no icon, so it takes the BASE chip
(`--lp-primary`, 7.71:1) and that is where the letters go. Forcing a tone
onto the initials, or letters onto a toned chip, would put 12px text at
3.29:1 on screen. The two branches agree by construction; keep them
agreeing. */}
<span className={dbChipClass(tile.icon) + " home-tile-chip"} aria-hidden="true">
{tile.icon ? <FolderMark icon={tile.icon} size={16} /> : tile.initials}
</span>
<span className="home-tile-text">
<span className="home-tile-name">{tile.label}</span>
{/* ⚠ CONDITIONAL, because `ago` is EMPTY for a database nobody has opened β€”
the state R10 makes visible for the first time. An unconditional span
would render an empty line and push every tile in the row taller than the
ones beside it; and `agoText` clamps a missing stamp to "Opened just now",
so printing it would be the surface stating a fact nobody measured. */}
{tile.ago ? <span className="home-tile-ago">{tile.ago}</span> : null}
</span>
</a>
{known ? (
<StarButton
on={dbStarred.has(tile.key)}
label={tile.label}
busy={busy}
onToggle={() => toggle("database", tile.key, !dbStarred.has(tile.key))}
/>
) : null}
</div>
))}
</div>
</section>
))
)}
{/* ⭐ WAVE 24 item 13 (R10) β€” THE AUTOMATIONS SECTION.
Same tiles, same rhythm.
⭐ WAVE 26 ITEM 18 (R14) β€” AND NO LONGER A STATUS DOT. This comment used to call
`.auto-dot is-*` "the ONE status vocabulary this product has"; R14 deleted that
vocabulary from the automation module entirely, so the sentence had to go with it rather
than sit here describing a class nothing renders. Each tile carries the Loopable loop
mark instead β€” the SAME mark on every automation, no status colour β€” and the run state
has a home one click away, in the Builder's "Last run succeeded / failed" lines that R13
explicitly keeps.
β›” THE STATE IS NOT MOVED INTO `title`/`aria-label` EITHER. Hanging "Last run failed"
off a deliberately status-free mark just relocates the channel to somewhere invisible to
the eye and announced to a screen reader β€” worse than deleting it, and contradicting the
visual. `AutomationTile` drops `state` for the same reason: a field nothing reads is a
fact the next reader will re-render.
⚠ A BUTTON, NOT A LINK. Opening an automation is a hash change PLUS a request to the
surface for which one is selected β€” `AutomationSurface` owns `activeId` and this page
cannot see it. An `<a href="#/automation">` would land the reader on the surface with
whatever was selected last, which is the wrong automation and looks like a bug.
⚠ EMPTY RENDERS NOTHING. `[]` is a real answer β€” no automations, or no grant (the frame
turns a 403 into `[]`) β€” and a heading over nothing says "there should be something
here", which is a different and false statement. */}
{automations.length ? (
<section className="home-section">
{/* β›” W34-T40's `done-when` names FOUR surfaces that must read Agents, and this was the
one that did not: a hardcoded `"Automations"` that never consulted the module label.
QA 2026-08-17. The rail row and the page heading both read `.label` off the registry
entry, which is why they moved and this did not.
⚠ It takes the SHARED constant, not a second literal spelling it correctly today.
The gate that was supposed to catch this pinned the string `Automations</h2>` and so
REQUIRED the defect β€” [[a-gate-that-pins-a-spelling-not-a-claim]]. Re-typing "Agents"
here would have left the next rename in exactly the same trap. */}
<h2 className="home-section-title">{AGENTS_MODULE_LABEL}</h2>
<div className={"home-tiles is-" + layout}>
{automations.map((a) => (
<div key={a.id} className="st-tilewrap">
<button
type="button"
className="home-tile home-tile-auto"
onClick={() => onOpenAutomation(a.id)}
>
{/* 16px matches the icon this replaces exactly (`.home-card-icon` was 16Γ—16), and
`Mark` writes width/height as ATTRIBUTES while `.lp-mark` sets neither β€” so the
tile's geometry is unchanged and this needs no `index.css` edit. Deliberate:
that file is shared with two other sessions this wave. */}
<span className="home-tile-chip shell-db-chip" aria-hidden="true">
<Mark size={16} />
</span>
<span className="home-tile-text">
<span className="home-tile-name">{a.name}</span>
{a.sub ? <span className="home-tile-ago">{a.sub}</span> : null}
</span>
</button>
{known ? (
<StarButton
on={agentStarred.has(a.id)}
label={a.name}
busy={busy}
onToggle={() => toggle("agent", a.id, !agentStarred.has(a.id))}
/>
) : null}
</div>
))}
</div>
</section>
) : null}
{/* ⭐ WAVE 35 Β· W35-T16 (owner item 9) β€” THE STARRED VIEWS.
Owner: *"remove [the important count] anywhere else but the View itself … Instead, let's
add these Views under 'Home' and … under 'Starred' module as well."* So the badge leaves
the rail and the flyout (A's W35-T07, E's W35-T43) and the VIEWS themselves arrive here.
β›” IT IS THE SAME `Tile` COMPONENT `StarredPage` DRAWS, not a copy that agrees today.
Item 11's complaint is that what is shown twice goes out of step, and two renderers for
one object is how that starts. The chip, the two lines and the star are one piece of code.
⚠ EMPTY RENDERS NOTHING, like every other section here: no starred views is a real
answer, and a heading over nothing says "there should be something here", which is a
different and false statement. */}
{starredViews.length ? (
<section className="home-section">
<h2 className="home-section-title">Starred views</h2>
<div className={"home-tiles is-" + layout}>
{starredViews.map((row) => (
<Tile
key={row.id}
row={row}
count={viewCount(row.id, counts)}
star={
known ? (
<StarButton
on
label={row.label}
busy={busy}
onToggle={() => toggle("view", row.id, false, row.database)}
/>
) : undefined
}
onOpen={() => onOpenView(row.database, row.id)}
/>
))}
</div>
</section>
) : null}
</div>
);
}
/**
* The page the frame mounts. Its props are exactly what they were before wave 35.
*
* ⭐ THE ONE READ HOME MAKES, and it is deliberately the SAME hook Starred uses. Two copies of
* "flip it, post it, take the server's answer, put it back if it was refused" is
* [[one-question-two-normalizers]] with the divergence on screen: one surface optimistic, the
* other reconciled, and the two disagreeing about what is starred.
* ⚠ It reads AFTER PAINT (the hook's own `useEffect`, never `useLayoutEffect`), because
* `GET /starred` scans a view bucket per database β€” C2 forbids a second store for the flag β€” and
* was measured at 6.2 ms warm and 7,331 ms COLD on tenant #0 (mailbox E-2).
*/
export default function HomePage(props: HomeProps) {
const { load: starLoad, starred, busy, counts, toggle } = useStarred();
return (
<HomeSurface
{...props}
starLoad={starLoad}
starred={starred}
busy={busy}
counts={counts}
toggle={toggle}
/>
);
}