| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useState } from "react"; |
| import { FolderMark } from "../customer-grid/icons"; |
| |
| |
| |
| |
| |
| import { AGENTS_MODULE_LABEL } from "../inbox/inboxModel"; |
| import { Mark } from "../shell/Brand"; |
| import { dbChipClass } from "../shell/nav"; |
| import type { DatabaseEntry, Recent } from "../shell/nav"; |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| export interface HomeProps { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| entries: DatabaseEntry[]; |
| recents: Recent[]; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| automations: AutomationTile[]; |
| onTemplates: () => void; |
| onNewDatabase: () => void; |
| |
| onConnectors: () => void; |
| |
| onOpenAutomation: (id: string) => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| onOpenView: (database: string, viewId: string) => void; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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} |
|
|
| { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| {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> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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} |
| /> |
| ); |
| } |
|
|