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.
svelte-kit-vice/web/routes/blocks/app-shell/AppShellSite.svelte

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>

Powered by TurnKey Linux.