Meowland docs
Workbench

Component reference

PageFrame

A frame with fixed chrome and a body that takes the remaining space; the parent owns the chrome.

Preview

header, notice, messages and composer

One public composition for a small conversation screen: PageFrame owns the header, notice, scroll body and footer; AppBar carries identity and actions; ComposerPanel owns the writing surface.

packages/meowtail/stories/app-shell.stories.tsxline 33tsx
render: () => (
  <div
    style={{
      height: "min(680px, calc(100dvh - 90px))",
      minHeight: 0,
      width: "min(100%, 430px)",
      margin: "0 auto",
      overflow: "hidden",
      border: "1px solid var(--ui-line-control)",
      borderRadius: "14px",
    }}
  >
    <PageFrame
      variant="workspace"
      header={
        <AppBar
          title="Studio MacBook"
          status={<ChatStatus label="Offline · Draft saved" tone="waiting" />}
          actions={[
            <Button variant="quiet" size="small">
              Reconnect
            </Button>,
            <IconButton label="Conversation actions">
              <Icon name="menu" />
            </IconButton>,
          ]}
        />
      }
      notice={
        <Alert
          layout="bar"
          tone="warning"
          actions={
            <Button variant="quiet" size="small">
              View receipt
            </Button>
          }
        >
          Last submission unverified; draft kept.
        </Alert>
      }
      footer={
        <ComposerPanel
          placeholder="Type a message"
          inputLabel="Message"
          submitLabel="Send message"
          submitContent={<Icon name="arrow-up" />}
        >
          <IconButton label="Add">
            <Icon name="plus" />
          </IconButton>
        </ComposerPanel>
      }
    >
      <ChatConversation ariaLabel="Conversation">
        <ChatMessageList ariaLabel="Messages">
          <ChatMessageListDate label="Today 20:40" />
          <ChatMessage role="assistant" author="Assistant" timestamp="20:40">
            <p>
              You do not have to clear the desk completely. Keep a small “in progress” area so
              things have a fixed home without having to dig them out every time.
            </p>
            <p>
              Leave a book-sized area on the other side of your dominant hand for the two or three
              books you are reading; keep the cable you are using in place and tuck the extra
              length behind the desk.
            </p>
          </ChatMessage>
          <ChatMessage role="user" author="You" timestamp="20:41">
            <p>
              A few more conditions: the desk is by the window and gets afternoon sun; I want my
              usual pen, headphones and two current books within reach.
            </p>
          </ChatMessage>
          <ChatMessage role="assistant" author="Assistant" timestamp="20:42">
            <p>
              That works. Keep the window side for the pen, headphones and those two books; split
              cables into “in use / spare / archive” in three places.
            </p>
          </ChatMessage>
        </ChatMessageList>
      </ChatConversation>
    </PageFrame>
  </div>
)

Installation

bun add meowtail

Usage

usage.tsts
import { PageFrame } from "meowtail";

Examples

The other stories that show this component.

Wrapping title and catalog filtering

The title, description, refresh action, and categories share their natural height; the body claims the remaining space on its own, and header controls are not clipped by percentages.

packages/meowtail/stories/dialog-layout.stories.tsxline 137tsx
render() {
  const selected = signal("all");
  return (
    <div style={{ height: "calc(100dvh - 90px)", minHeight: 0 }}>
      <PageFrame
        scrollOwner="layout"
        header={
          <Stack gap="compact">
            <PageHeader
              title="Extension catalog"
              description="Browse authorized capabilities and pages; categories do not change the identity of the content."
              actions={<Button variant="outline">Refresh catalog</Button>}
            />
            <SegmentedControl
              id="catalog-kind"
              label="Catalog categories"
              value={selected}
              onValueChange={(value) => {
                selected.value = value;
              }}
              options={[
                { value: "all", label: "All" },
                { value: "local", label: "Local" },
                { value: "enabled", label: "Enabled" },
              ]}
            />
          </Stack>
        }
      >
        <Stack>
          {Array.from({ length: 20 }, (_, index) => (
            <p>Catalog content {index + 1}: The main region keeps its own scroll range.</p>
          ))}
        </Stack>
      </PageFrame>
    </div>
  );
}

Continuous reading and draft workspace

The page shell allocates the remaining space, a long draft scrolls inside the input, and the inline toolbar expansion and native popovers stay reachable.

packages/meowtail/stories/region-layout.stories.tsxline 229tsx
render: () => (
  <ChatFrame style={{ height: "calc(100dvh - 90px)", minHeight: 0 }}>
    <PageFrame
      variant="workspace"
      toolbar={
        <Inline gap="compact" style={{ position: "relative", zIndex: 2 }}>
          <strong>Assistant</strong>
          {/* A host's own inline expansion: a quiet trigger and a floating panel over the content. */}
          <details style={{ position: "relative" }}>
            <summary
              style={{
                display: "inline-flex",
                alignItems: "center",
                gap: "4px",
                padding: "4px 8px",
                borderRadius: String(uiTokens.radius.md),
                listStyle: "none",
                color: String(uiTokens.ink.secondary),
                fontWeight: 500,
                cursor: "pointer",
              }}
            >
              Current work
              <Icon name="chevron-down" size={14} />
            </summary>
            <div
              data-workspace-inline-panel
              style={{
                position: "absolute",
                top: "calc(100% + 6px)",
                zIndex: 2,
                display: "grid",
                justifyItems: "start",
                gap: String(uiTokens.space.group),
                width: "min(280px, 80vw)",
                padding: String(uiTokens.space.section),
                borderRadius: String(uiTokens.radius.xl),
                background: String(uiTokens.surface.overlay),
                boxShadow: String(uiTokens.shadow.float),
              }}
            >
              <p style={{ margin: 0 }}>
                Review the current work and its evidence, then close this and keep reading.
              </p>
              <Button size="small">View details</Button>
            </div>
          </details>
          <Button variant="outline" size="small">
            Stop
          </Button>
        </Inline>
      }
      header={
        <span role="status">Shared layout example · Fixed content, no real model requests</span>
      }
    >
      <div
        ref={(host: HTMLElement) => {
          const surface = mountRegionLayout(host, {
            areas: [["messages"], ["compose"]],
            rows: ["fill", "content"],
            gap: "none",
            regions: {
              messages: { scrollOwner: "content" },
              compose: {},
            },
          });
          const draft = signal("");
          const mounts = [
            render(
              <ChatConversation ariaLabel="Workspace messages">
                {Array.from({ length: 30 }, (_, i) => (
                  <p>
                    Message {i + 1}: The same reading area keeps its position, so long content can
                    still be checked after the window changes.
                  </p>
                ))}
              </ChatConversation>,
              surface.regions.messages!,
            ),
            render(
              <ChatComposer
                layout="stacked"
                inputLabel="Workspace draft"
                placeholder="What should we push forward together?"
                value={draft}
                onInput={(event: Event) => {
                  draft.value = (event.target as HTMLTextAreaElement).value;
                }}
                onSubmit={(event: Event) => event.preventDefault()}
                trailing={
                  <ChoiceSelect
                    id="workspace-model"
                    label="Example model"
                    value="one"
                    placement="top"
                    align="end"
                    matchTriggerWidth={false}
                    options={[
                      { value: "one", label: "Local example model" },
                      { value: "two", label: "Another example model" },
                    ]}
                  />
                }
                submitLabel="Send"
              >
                <IconButton label="Add material">
                  <Icon name="plus" />
                </IconButton>
              </ChatComposer>,
              surface.regions.compose!,
            ),
          ];
          return () => {
            for (const mount of mounts) mount.unmount();
            surface.dispose();
          };
        }}
      />
    </PageFrame>
  </ChatFrame>
)

Fixed header, form and dynamic body

One focused content page: keep the draft, inspect the checks, and finish with one action; a short narrow window still returns to the page header and never clips an overlay.

packages/meowtail/stories/workspace-composition.stories.tsxline 40tsx
render: () => {
  const expanded = signal(false);
  const draft = signal("");
  const feedback = signal("The local draft has not been checked yet.");
  return (
    <div style={{ height: "min(640px, calc(100dvh - 90px))" }} data-workspace-composition>
      <PageFrame
        variant="page"
        scrollOwner="layout"
        header={
          <Container width="content">
            <PageHeader
              title="Check the draft"
              description="Keep the current input, inspect the checks, and finish with one action."
            />
          </Container>
        }
      >
        <Container width="content">
          <Stack gap="relaxed">
            <Card>
              <CardContent>
                <Field
                  label="Composition draft"
                  htmlFor="workspace-composition-draft"
                  description="The same input node stays mounted when the window or grid changes."
                >
                  <Input
                    id="workspace-composition-draft"
                    value={draft}
                    placeholder="Write the current composition draft"
                    onInput={(event: Event) => {
                      draft.value = (event.currentTarget as HTMLInputElement).value;
                    }}
                  />
                </Field>
              </CardContent>
            </Card>

            <Stack gap="compact" data-workspace-composition-content>
              <Inline justify="between" align="center">
                <strong>Checks</strong>
                <Toolbar label="Check controls">
                  <Popover
                    id="workspace-review-options"
                    trigger={(bindings) => (
                      <IconButton {...bindings} label="Check options">
                        <Icon name="settings" />
                      </IconButton>
                    )}
                  >
                    <Stack gap="compact" align="start">
                      <span>Keep checking the current draft after the window changes.</span>
                      <Button
                        size="small"
                        onClick={() => {
                          feedback.value = "Checked the current local draft.";
                        }}
                      >
                        Check current draft
                      </Button>
                    </Stack>
                  </Popover>
                  <Button
                    variant="quiet"
                    size="small"
                    onClick={() => {
                      expanded.value = !expanded.value;
                    }}
                  >
                    <Icon name="list-checks" />
                    {mapSignal(expanded, (value) =>
                      value ? "Collapse checks" : "Expand checks",
                    )}
                  </Button>
                </Toolbar>
              </Inline>
              <ListGroup>
                {mapSignal(expanded, (value) =>
                  Array.from({ length: value ? 28 : 10 }, (_, index) => (
                    <ListRow
                      title={`Check ${index + 1}`}
                      description="Dynamic content grows in the natural text flow; the body keeps scrolling while the page header stays put."
                      status={<Status state="success" label="Ready" />}
                    />
                  )),
                )}
              </ListGroup>
            </Stack>

            <Inline as="footer">
              <Button
                onClick={() => {
                  feedback.value = "Checked the current local draft.";
                }}
              >
                Check the draft at the end
              </Button>
              <span role="status">{feedback}</span>
            </Inline>
          </Stack>
        </Container>
      </PageFrame>
    </div>
  );
}

API reference

Props

PropTypeDefault
variant
"page" | "workspace""page"
scrollOwner
"content" | "layout""content"
header
Child–
toolbar
Child–
noticeA full-width status or alert row between the header and the body.
Child–
footerA persistent region after the body, typically a composer or action bar.
Child–

Also takes className, children and the attributes and events of its root element.

DOM parts

roottoolbarheadernoticebodyfooter

Registry

The component is registered as page-frame in the meowtail component registry.