You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
561 lines
19 KiB
561 lines
19 KiB
<script lang="ts">
|
|
/**
|
|
* A real application inside the shell — the only way to see what a shell
|
|
* does. A frame with lorem in it would show the grid and hide the point:
|
|
* the rail that collapses, the bar that stays put while the page scrolls,
|
|
* the aside that folds when the width runs out, and the bypass links that
|
|
* appear on the first Tab.
|
|
*
|
|
* Two rules this file obeys, both learnt the expensive way on 2026-08-19:
|
|
*
|
|
* 1. Every piece is the canon component that MEANS what it shows, consumed
|
|
* through its props — `Metrics` for a KPI, `Feed` for a stream of
|
|
* articles, `Toolbar` for the action cluster, `Breadcrumb` for the trail,
|
|
* `Sidebar` for the rail. Not one `<div style>`, not one literal length,
|
|
* colour or font size: colour by role token, spacing by `--space-*`
|
|
* through the layout props, elevation by `Card`. A hand-rolled panel
|
|
* where a primitive exists is the drift the build contract names
|
|
* (`guides/component-guide.md` §Build contract).
|
|
* 2. Type comes from the theme's SEMANTIC styles, never from a size picked
|
|
* by eye: `Heading` takes `level` (the outline) and `style` (which of the
|
|
* theme's `--style-h*` treatments), `Text` takes `style="label"` /
|
|
* `"caption"` / `"body"`. The theme decides what a label looks like; the
|
|
* page decides which things are labels.
|
|
*/
|
|
import { AppShell, type AppShellScroll } from '$blocks/app-shell';
|
|
import { Sidebar } from '$uix/eidos/components/sidebar';
|
|
import { Avatar } from '$uix/eidos/components/avatar';
|
|
import { Badge } from '$uix/eidos/components/badge';
|
|
import { Banner } from '$uix/eidos/components/banner';
|
|
import { Box } from '$uix/eidos/components/box';
|
|
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
|
|
import { Button } from '$uix/eidos/components/button';
|
|
import { Card } from '$uix/eidos/components/card';
|
|
import { Container } from '$uix/eidos/components/container';
|
|
import { Feed } from '$uix/eidos/components/feed';
|
|
import { Flex } from '$uix/eidos/components/flex';
|
|
import { Grid } from '$uix/eidos/components/grid';
|
|
import { Group } from '$uix/eidos/components/group';
|
|
import { Heading } from '$uix/eidos/components/heading';
|
|
import * as Icon from '$uix/eidos/components/icon';
|
|
import { Metrics } from '$uix/eidos/components/metrics';
|
|
import { NavigationMenu } from '$uix/eidos/components/navigation-menu';
|
|
import { Separator } from '$uix/eidos/components/separator';
|
|
import { Stack } from '$uix/eidos/components/stack';
|
|
import { Text } from '$uix/eidos/components/text';
|
|
import { Toolbar } from '$uix/eidos/components/toolbar';
|
|
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
|
|
|
|
let {
|
|
scroll = 'main' as AppShellScroll,
|
|
collapsible = 'icon' as SidebarCollapsible,
|
|
rail = true,
|
|
aside = true,
|
|
footer = true,
|
|
banner = false
|
|
}: {
|
|
scroll?: AppShellScroll;
|
|
collapsible?: SidebarCollapsible;
|
|
rail?: boolean;
|
|
aside?: boolean;
|
|
footer?: boolean;
|
|
banner?: boolean;
|
|
} = $props();
|
|
|
|
let navOpen = $state(true);
|
|
let active = $state('/apollo/inbox');
|
|
let noticeUp = $state(true);
|
|
|
|
const kpis = [
|
|
{ label: 'Abiertos', value: 128, delta: '12%', trend: 'up' as const, note: 'esta semana' },
|
|
{
|
|
label: 'Sin asignar',
|
|
value: 17,
|
|
delta: '4',
|
|
trend: 'down' as const,
|
|
goodTrend: 'down' as const,
|
|
note: 'desde ayer'
|
|
},
|
|
{
|
|
label: 'Cerrados hoy',
|
|
value: 42,
|
|
delta: '2',
|
|
trend: 'up' as const,
|
|
note: 'sobre el objetivo'
|
|
}
|
|
];
|
|
|
|
const conversations = [
|
|
{
|
|
id: 'ada',
|
|
from: 'Ada Lovelace',
|
|
initials: 'AL',
|
|
subject: 'Notas de la máquina analítica, v2',
|
|
summary: 'Adjunto la segunda pasada. Los diagramas de las tarjetas siguen sin cuadrar.',
|
|
when: '09:41',
|
|
unread: true
|
|
},
|
|
{
|
|
id: 'grace',
|
|
from: 'Grace Hopper',
|
|
initials: 'GH',
|
|
subject: 'El compilador pasa en verde',
|
|
summary: 'Toda la batería en verde por primera vez. Falta el caso de la coma decimal.',
|
|
when: '08:12',
|
|
unread: true
|
|
},
|
|
{
|
|
id: 'karen',
|
|
from: 'Karen Spärck Jones',
|
|
initials: 'KS',
|
|
subject: 'Pesos de ranking para el índice',
|
|
summary: 'Propongo IDF sobre el corpus entero, no por colección.',
|
|
when: 'Ayer',
|
|
unread: false
|
|
},
|
|
{
|
|
id: 'radia',
|
|
from: 'Radia Perlman',
|
|
initials: 'RP',
|
|
subject: 'El árbol de expansión, revisitado',
|
|
summary: 'Un poema, y una prueba de que converge.',
|
|
when: 'Ayer',
|
|
unread: false
|
|
},
|
|
{
|
|
id: 'barbara',
|
|
from: 'Barbara Liskov',
|
|
initials: 'BL',
|
|
subject: 'Sustitución en la API nueva',
|
|
summary: 'Si un subtipo no puede sustituir al tipo, no es un subtipo.',
|
|
when: 'Lunes',
|
|
unread: false
|
|
}
|
|
];
|
|
|
|
const activity = [
|
|
{
|
|
id: 1,
|
|
who: 'Grace H.',
|
|
what: 'cerró la conversación «Compilador» como resuelta',
|
|
when: 'hace 12 min'
|
|
},
|
|
{ id: 2, who: 'Ada L.', what: 'reasignó «Máquina analítica» a Radia P.', when: 'hace 40 min' },
|
|
{ id: 3, who: 'Karen S.', what: 'añadió la etiqueta «índice»', when: 'hace 2 h' },
|
|
{
|
|
id: 4,
|
|
who: 'Barbara L.',
|
|
what: 'comentó en «Sustitución en la API nueva»',
|
|
when: 'hace 3 h'
|
|
},
|
|
{ id: 5, who: 'Radia P.', what: 'abrió una conversación nueva', when: 'ayer' },
|
|
{ id: 6, who: 'Sistema', what: 'importó 14 conversaciones del buzón compartido', when: 'ayer' }
|
|
];
|
|
|
|
/**
|
|
* THE APPLICATION's menu — the bar. It names the areas the product has, and
|
|
* it does not change when the reader moves inside one of them.
|
|
*/
|
|
const appMenu = [
|
|
{ href: '/inbox', label: 'Bandeja' },
|
|
{ href: '/projects', label: 'Proyectos' },
|
|
{ href: '/reports', label: 'Informes' }
|
|
];
|
|
|
|
/**
|
|
* THE CONTEXT's rail — the sections and actions of the project the reader
|
|
* has open. Change project and this whole list changes; the bar above does
|
|
* not move. That is the split the shell exists to hold (Atlassian's TopNav +
|
|
* SideNav, Polaris' TopBar + Navigation).
|
|
*/
|
|
const contextNav = [
|
|
{
|
|
group: 'Apollo',
|
|
items: [
|
|
{ href: '/apollo/board', label: 'Tablero', icon: Icon.LayoutGrid },
|
|
{ href: '/apollo/inbox', label: 'Conversaciones', icon: Icon.Inbox, badge: '5' },
|
|
{ href: '/apollo/files', label: 'Archivos', icon: Icon.Folder }
|
|
]
|
|
},
|
|
{
|
|
group: 'Acciones',
|
|
items: [
|
|
{ href: '/apollo/new', label: 'Nueva conversación', icon: Icon.Plus },
|
|
{ href: '/apollo/members', label: 'Miembros', icon: Icon.Users },
|
|
{ href: '/apollo/settings', label: 'Ajustes del proyecto', icon: Icon.Settings }
|
|
]
|
|
}
|
|
];
|
|
</script>
|
|
|
|
{#snippet notice()}
|
|
{#if banner && noticeUp}
|
|
<Banner intent="primary" variant="solid" aria-label="Aviso de producto">
|
|
<Container width="100%" paddingX={0}>
|
|
<Group gap={3} align="center" justify="space-between">
|
|
<Text style="body" size="sm">La facturación cambia el 1 de septiembre.</Text>
|
|
<Banner.Close onclick={() => (noticeUp = false)} />
|
|
</Group>
|
|
</Container>
|
|
</Banner>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet navContent()}
|
|
<!-- No `Sidebar.Panel` here: the shell owns it (it is the landmark its skip
|
|
link jumps to). The app fills the panel, it does not re-open it. -->
|
|
<!--
|
|
The rail's head names the CONTEXT, not the product: the product is the
|
|
bar's, and repeating it here would spend the one line that tells the
|
|
reader where they are.
|
|
-->
|
|
<Sidebar.Header>
|
|
<Group gap={2} align="center">
|
|
<Icon.Folder size="sm" />
|
|
<Stack gap={0}>
|
|
<Text style="label">Apollo</Text>
|
|
<Text style="caption">Proyecto</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Sidebar.Header>
|
|
<Sidebar.Content>
|
|
{#each contextNav as section (section.group)}
|
|
<Sidebar.Group>
|
|
<Sidebar.GroupLabel>{section.group}</Sidebar.GroupLabel>
|
|
<Sidebar.Menu>
|
|
{#each section.items as item (item.href)}
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton
|
|
href={`#${item.href}`}
|
|
active={active === item.href}
|
|
tooltip={item.label}
|
|
onclick={(e: MouseEvent) => {
|
|
e.preventDefault();
|
|
active = item.href;
|
|
}}
|
|
>
|
|
<item.icon size="sm" />
|
|
<span>{item.label}</span>
|
|
{#if item.badge}
|
|
<Sidebar.MenuBadge>{item.badge}</Sidebar.MenuBadge>
|
|
{/if}
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
{/each}
|
|
</Sidebar.Menu>
|
|
</Sidebar.Group>
|
|
{/each}
|
|
</Sidebar.Content>
|
|
<Sidebar.Footer>
|
|
<Group gap={2} align="center">
|
|
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
|
|
<Text style="caption">Juan V.</Text>
|
|
</Group>
|
|
</Sidebar.Footer>
|
|
{/snippet}
|
|
|
|
<!--
|
|
The top bar. Two semantic regions, not one row of buttons: the TRAIL on the
|
|
start side (`Breadcrumb` — a `<nav>` naming where the reader is) and the
|
|
ACTION CLUSTER on the end side (`Toolbar` — `role="toolbar"`, roving tabindex,
|
|
arrow keys, so five controls cost one tab stop). The rail trigger sits before
|
|
the trail because it is chrome, not an action on this page.
|
|
-->
|
|
{#snippet header()}
|
|
<Stack gap={0}>
|
|
<Box paddingX={4} paddingY={2}>
|
|
<Group gap={3} align="center" justify="space-between">
|
|
<Group gap={4} align="center">
|
|
{#if rail}
|
|
<Sidebar.Trigger><Icon.PanelLeft size="sm" /></Sidebar.Trigger>
|
|
{/if}
|
|
<Group gap={2} align="center">
|
|
<Icon.Sparkles size="sm" />
|
|
<Text style="label">Acme</Text>
|
|
</Group>
|
|
<!--
|
|
THE APPLICATION's menu — a `NavigationMenu`, its own `<nav>`
|
|
landmark, at a different scope from the rail's.
|
|
|
|
⚠️ Its landmark ships ANONYMOUS and that is not this file's
|
|
doing: the `aria-label` below reaches the `<ul>`, never the
|
|
`<nav>` — the morfo delegates the root's name to the list it
|
|
wraps. A `<ul>` is not a landmark, and this page has three
|
|
navigations, so the APG's unique-name rule is unmet.
|
|
Registered in the block's Gaps and left VISIBLE (measured:
|
|
`navigation: ['Navegación principal', null, 'Migas de pan']`)
|
|
rather than faked with a second `<nav>` around it, which
|
|
would nest one landmark inside another.
|
|
-->
|
|
<NavigationMenu aria-label="Menú de la aplicación">
|
|
<NavigationMenu.List>
|
|
{#each appMenu as entry (entry.href)}
|
|
<!-- `value` is required: it is the item's identity for the
|
|
open-menu state, even on an item that only links. -->
|
|
<NavigationMenu.Item value={entry.href}>
|
|
<NavigationMenu.Link
|
|
href={`#${entry.href}`}
|
|
active={entry.href === '/projects'}
|
|
onclick={(e: MouseEvent) => e.preventDefault()}
|
|
>
|
|
{entry.label}
|
|
</NavigationMenu.Link>
|
|
</NavigationMenu.Item>
|
|
{/each}
|
|
</NavigationMenu.List>
|
|
</NavigationMenu>
|
|
</Group>
|
|
<Group gap={2} align="center">
|
|
<Toolbar aria-label="Acciones de la página" size="sm" variant="ghost">
|
|
<Toolbar.Button>
|
|
<Icon.Search size="sm" />
|
|
Buscar
|
|
</Toolbar.Button>
|
|
<Toolbar.Button>
|
|
<Icon.Bell size="sm" />
|
|
Avisos
|
|
</Toolbar.Button>
|
|
<Toolbar.Separator />
|
|
<Toolbar.Button aria-label="Cuenta de Juan V.">
|
|
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
|
|
</Toolbar.Button>
|
|
</Toolbar>
|
|
<!--
|
|
The primary action stays OUTSIDE the toolbar, and that is a
|
|
finding rather than a preference: `ToolbarButtonProps` types to
|
|
SOMA's `ButtonProps` (`{ id, disabled }`), so a toolbar button
|
|
takes no `variant`/`color` — the treatment belongs to the
|
|
Toolbar root and every item wears it. A cluster where one action
|
|
is the principal one has no way to say so from inside. Registered
|
|
in the block's Gaps; here the canon `Button` sits beside the
|
|
cluster, which is also what the references do.
|
|
-->
|
|
<Button size="sm">
|
|
<Icon.Plus size="sm" />
|
|
Nueva
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
<Separator decorative />
|
|
</Stack>
|
|
{/snippet}
|
|
|
|
<!--
|
|
The detail panel. Its content is a DESCRIPTION LIST — term / detail pairs —
|
|
and the canon has no component for that yet (`description-list` sits in the
|
|
tier's F5 backlog, waiting for «the first real detail view»; this is it, and
|
|
it is registered in the block's Gaps). So the semantics come from the
|
|
elements that carry them, `dl` / `dt` / `dd`, and the type from the theme's
|
|
`label` and `body` styles — the honest form of a gap: name it, and use the
|
|
platform's own meaning while it waits, not a stack of divs pretending.
|
|
|
|
Two more measured decisions, both the system's: the width is
|
|
`--sidebar-width` (the system's own rail measure, so the two flanks agree),
|
|
and the boundary is a `Separator` — `Surface variant="soft"` was inert here,
|
|
`oklch(0.9911 0 0)` against a page ground of the same lightness (F15, a
|
|
registered canon gap: soft does not bound a panel).
|
|
-->
|
|
{#snippet asideContent()}
|
|
<Flex align="stretch" height="100%">
|
|
<Separator orientation="vertical" decorative />
|
|
<Box width="var(--sidebar-width)" padding={4}>
|
|
<Stack gap={4}>
|
|
<Stack gap={1}>
|
|
<Text style="caption">Conversación</Text>
|
|
<Heading level={2} style="h4">Detalle</Heading>
|
|
</Stack>
|
|
<Separator decorative />
|
|
<dl>
|
|
<Stack gap={4}>
|
|
<Stack gap={1}>
|
|
<dt><Text style="label" color="muted">Estado</Text></dt>
|
|
<dd><Badge size="sm" color="affirm" variant="soft">Abierta</Badge></dd>
|
|
</Stack>
|
|
<Stack gap={1}>
|
|
<dt><Text style="label" color="muted">Asignada a</Text></dt>
|
|
<dd>
|
|
<Group gap={2} align="center">
|
|
<Avatar size="xs"><Avatar.Fallback>GH</Avatar.Fallback></Avatar>
|
|
<Text style="body" size="sm">Grace H.</Text>
|
|
</Group>
|
|
</dd>
|
|
</Stack>
|
|
<Stack gap={1}>
|
|
<dt><Text style="label" color="muted">Etiquetas</Text></dt>
|
|
<dd>
|
|
<Group gap={2} align="center">
|
|
<Badge size="sm" variant="soft">infra</Badge>
|
|
<Badge size="sm" variant="soft">v2</Badge>
|
|
</Group>
|
|
</dd>
|
|
</Stack>
|
|
<Stack gap={1}>
|
|
<dt><Text style="label" color="muted">Última actividad</Text></dt>
|
|
<dd><Text style="body" size="sm">hace 12 min</Text></dd>
|
|
</Stack>
|
|
</Stack>
|
|
</dl>
|
|
</Stack>
|
|
</Box>
|
|
</Flex>
|
|
{/snippet}
|
|
|
|
{#snippet pageFooter()}
|
|
<Stack gap={0}>
|
|
<Separator decorative />
|
|
<Box paddingX={4} paddingY={3}>
|
|
<Group gap={3} align="center" justify="space-between">
|
|
<Text style="caption">© 2026 Acme</Text>
|
|
<Text style="caption">v0.1</Text>
|
|
</Group>
|
|
</Box>
|
|
</Stack>
|
|
{/snippet}
|
|
|
|
<AppShell
|
|
{scroll}
|
|
bind:navOpen
|
|
nav={{ collapsible, mobileBreakpoint: 'md' }}
|
|
aside={{ breakpoint: 'lg' }}
|
|
navLabel="Proyecto Apollo"
|
|
asideLabel="Detalle de la conversación"
|
|
banner={banner ? notice : undefined}
|
|
navContent={rail ? navContent : undefined}
|
|
{header}
|
|
asideContent={aside ? asideContent : undefined}
|
|
footer={footer ? pageFooter : undefined}
|
|
>
|
|
<Box padding={5}>
|
|
<Stack gap={6}>
|
|
<!--
|
|
The page's own header: an `h1` at the theme's `h3` treatment. `level`
|
|
is the outline (this is the page's one h1); `style` is the theme's
|
|
register for chrome-sized titles — the display serif of `h1`/`h2`
|
|
belongs to a hero, not to a bandeja.
|
|
-->
|
|
<!--
|
|
The trail belongs to the PAGE, not to the application's bar: it
|
|
says where this view sits inside the context, and it changes with
|
|
every view while the bar does not. Atlassian, Toolpad and GitHub
|
|
all put it here for the same reason — a bar that carries the trail
|
|
grows a second row the moment the path is three deep, which is
|
|
what this demo measured before moving it.
|
|
-->
|
|
<Group gap={3} align="end" justify="space-between">
|
|
<Stack gap={2}>
|
|
<Breadcrumb size="sm">
|
|
<Breadcrumb.List>
|
|
<Breadcrumb.Item>
|
|
<Breadcrumb.Link href="#/projects">Proyectos</Breadcrumb.Link>
|
|
</Breadcrumb.Item>
|
|
<Breadcrumb.Separator />
|
|
<Breadcrumb.Item>
|
|
<Breadcrumb.Link href="#/apollo/board">Apollo</Breadcrumb.Link>
|
|
</Breadcrumb.Item>
|
|
<Breadcrumb.Separator />
|
|
<Breadcrumb.Item>
|
|
<Breadcrumb.Link current>Conversaciones</Breadcrumb.Link>
|
|
</Breadcrumb.Item>
|
|
</Breadcrumb.List>
|
|
</Breadcrumb>
|
|
<!-- No eyebrow: the trail above already says where this is, and
|
|
a caption repeating it is a line that earns nothing. -->
|
|
<Heading level={1} style="h3">Conversaciones</Heading>
|
|
</Stack>
|
|
<Toolbar aria-label="Vista" size="sm" variant="outline">
|
|
<Toolbar.Button>
|
|
<Icon.ListFilter size="sm" />
|
|
Filtrar
|
|
</Toolbar.Button>
|
|
<Toolbar.Button>
|
|
<Icon.ArrowUpDown size="sm" />
|
|
Ordenar
|
|
</Toolbar.Button>
|
|
</Toolbar>
|
|
</Group>
|
|
|
|
<!-- KPIs are `Metrics` — label / value / delta with the direction
|
|
decoupled from the valence: «sin asignar» FALLING is good. -->
|
|
<Grid columns={{ base: 1, sm: 3 }} gap={4}>
|
|
{#each kpis as kpi (kpi.label)}
|
|
<Card variant="outline">
|
|
<Metrics size="md">
|
|
<Metrics.Label>{kpi.label}</Metrics.Label>
|
|
<Metrics.Value value={kpi.value}>{kpi.value}</Metrics.Value>
|
|
<Metrics.Trend>
|
|
<Metrics.Delta trend={kpi.trend} goodTrend={kpi.goodTrend} value={kpi.delta} />
|
|
<Metrics.Description>{kpi.note}</Metrics.Description>
|
|
</Metrics.Trend>
|
|
</Metrics>
|
|
</Card>
|
|
{/each}
|
|
</Grid>
|
|
|
|
<!-- The inbox is a `Feed`: `role="feed"`, each conversation an
|
|
`article` with its position in the set, PageUp/PageDown between
|
|
them — the APG pattern, not a stack of rows. -->
|
|
<Stack gap={3}>
|
|
<Heading level={2} style="h4">Conversaciones</Heading>
|
|
<Card variant="outline">
|
|
<Feed
|
|
aria-label="Conversaciones de la bandeja"
|
|
totalItems={conversations.length}
|
|
size="md"
|
|
>
|
|
{#each conversations as c, i (c.id)}
|
|
<Feed.Article posinset={i + 1}>
|
|
<Group gap={3} align="start" justify="space-between">
|
|
<Group gap={3} align="start">
|
|
<Avatar size="sm"><Avatar.Fallback>{c.initials}</Avatar.Fallback></Avatar>
|
|
<Stack gap={1}>
|
|
<Feed.ArticleTitle level={3}>
|
|
<Text style="label">{c.from}</Text>
|
|
{#if c.unread}
|
|
<Badge size="xs" color="primary" variant="soft">nuevo</Badge>
|
|
{/if}
|
|
</Feed.ArticleTitle>
|
|
<Text style="body" size="sm">{c.subject}</Text>
|
|
<Feed.ArticleDescription>
|
|
<Text style="caption">{c.summary}</Text>
|
|
</Feed.ArticleDescription>
|
|
</Stack>
|
|
</Group>
|
|
<Text style="caption" numeric="tabular">{c.when}</Text>
|
|
</Group>
|
|
</Feed.Article>
|
|
{/each}
|
|
</Feed>
|
|
</Card>
|
|
</Stack>
|
|
|
|
<!-- Enough page to make the scroll model visible: with `scroll="main"`
|
|
the bar above stays while this moves; with `body` the whole document
|
|
goes and the bar pins. Also a `Feed`, because that is what an
|
|
activity stream is. -->
|
|
<Stack gap={3}>
|
|
<Heading level={2} style="h4">Actividad</Heading>
|
|
<Feed aria-label="Actividad reciente" totalItems={activity.length} size="sm">
|
|
{#each activity as a, i (a.id)}
|
|
<Feed.Article posinset={i + 1}>
|
|
<Card variant="soft">
|
|
<Group gap={3} align="start" justify="space-between">
|
|
<Stack gap={1}>
|
|
<Feed.ArticleTitle level={3}>
|
|
<Text style="label">{a.who}</Text>
|
|
</Feed.ArticleTitle>
|
|
<Feed.ArticleDescription>
|
|
<Text style="body" size="sm">{a.what}</Text>
|
|
</Feed.ArticleDescription>
|
|
</Stack>
|
|
<Text style="caption" numeric="tabular">{a.when}</Text>
|
|
</Group>
|
|
</Card>
|
|
</Feed.Article>
|
|
{/each}
|
|
</Feed>
|
|
</Stack>
|
|
</Stack>
|
|
</Box>
|
|
</AppShell>
|