Draw the panel as a panel

Five things a wall panel showed that a wall panel should not.

**A tile's body no longer clips.** It scrolled, and a box that scrolls
also cuts whatever crosses its edge — which took the glow off a lit
button at exactly the width where the button filled its tile, and off a
gauge's arc at exactly the height where the dial filled its own. Only
what is written or listed asks for a scroller now; everything else is a
picture drawn to fit, and what overflows is left to the frame, which
clips at the tile's edge where a shadow has already faded out. The
slider's phantom scrollbar goes with it.

**The selector is a selector.** Named for what it does rather than what
it is, and the choice it is holding is held in the dashboard's own
primary — a pill that slides between the options rather than a grey one
that had to be looked for. The stored type is untouched, so no document
changes meaning.

**The arrangement is held off the panel's edges**, by the same distance
it holds between two widgets. The ground is not held off with it: a
background covers the whole panel, and only what is arranged on it has
a margin. No stored panel loses a row to it.

**The rail is drawn on the panel.** It was chrome bolted to the edge of
the screen beside the canvas — in the app's own design rather than the
dashboard's, and on a scaled canvas not even lined up with it. It now
takes a column out of the canvas the way the margin does, scaled with
it and wearing its look. Which cell each widget sits in is unchanged;
only how big a cell is.

Two of these were the same mistake twice: an unlayered rule stating
`position` for everything wearing a class — `.gl-surface` on a rail
placed by a utility, and a blanket lift over every child of a pressable
on a pill placed by `layoutId`. Both now say it one element at a time.
This commit is contained in:
2026-08-24 10:44:53 +02:00
parent fea57064f9
commit 6238728dce
18 changed files with 389 additions and 173 deletions
@@ -54,8 +54,8 @@ import { appPath } from "@/lib/portal"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
areaOf,
CanvasSurface,
canvasOf,
columnsOf,
type Dashboard,
DashboardView,
@@ -69,7 +69,7 @@ import {
sectionsOf,
widgetsOf,
} from "./DashboardView"
import { PanelRail, RAIL_INSET } from "./PanelRail"
import { PanelRail } from "./PanelRail"
import { DashboardPanel, WidgetPanel } from "./panels"
import {
dashboardKeys,
@@ -327,7 +327,6 @@ export function DashboardEditor({
// Every widget the panel shows, not just the first section's — a document
// written with several sections is one arrangement here, as it is there.
const widgets = page ? flatWidgets(page) : []
const canvas = canvasOf(draft)
const updateWidgets = (next: WidgetDef[]) => {
if (!page) return
@@ -381,8 +380,25 @@ export function DashboardEditor({
),
)
// A dashboard hanging on a panel beside others is drawn with a rail on it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const hasRail = Boolean(railDashboards) && !stacked
// What the arrangement is laid out in: the canvas less its margin, and less
// the rail's column when the panel carries one.
const area = areaOf(draft, hasRail)
const layout = layoutOf(widgets, columns)
const rows = rowsOf(draft)
const rows = rowsOf(draft, hasRail)
// maxRows below only constrains a *drag*. A stored placement is corrected
// against `cols` and nothing else, so a canvas that shrank leaves whatever
// now falls past its bottom edge drawn under the clip.
@@ -428,18 +444,6 @@ export function DashboardEditor({
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
// A dashboard hanging on a panel beside others is drawn with a rail over it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const setEdit = (next: boolean) => {
setSelected(null)
setSettingsOpen(false)
@@ -492,19 +496,37 @@ export function DashboardEditor({
/>
</div>
) : (
<CanvasSurface dashboard={draft} dots={edit}>
<CanvasSurface
dashboard={draft}
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} />
<DashboardView dashboard={draft} rail={hasRail} />
) : (
<GridLayout
width={canvas.width}
width={area.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{
cols: columns,
rowHeight: rowHeightOf(draft),
rowHeight: rowHeightOf(draft, hasRail),
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
@@ -534,28 +556,11 @@ export function DashboardEditor({
return (
<LookProvider dashboard={draft}>
{railDashboards && !stacked ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null}
<div
className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
style={
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
}
data-testid="dashboard-canvas"
>
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
@@ -566,7 +571,6 @@ export function DashboardEditor({
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]",
)}
style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
@@ -16,7 +16,7 @@ import {
useDashboardBackground,
} from "./settings"
import { useUi } from "./ui"
import { showTitle } from "./ui/core/config"
import { scrolls, showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look"
import { gridStagger, LOOK } from "./ui/core/motion"
import { WidgetBody, widgetIssue } from "./widgets"
@@ -44,6 +44,26 @@ export const CANVAS_PRESETS = [
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12
/**
* How far the arrangement keeps from the panel's own edges.
*
* The same as the gap between two widgets, so a tile sits as far from the edge
* of the screen as it does from its neighbour. The ground is not inset with it
* — a background covers the whole panel, and only what is arranged on it is
* held off the edge.
*/
export const GRID_INSET = GRID_GAP
/**
* The column a dashboard-switching rail takes out of the canvas, in canvas
* pixels.
*
* The rail is drawn on the panel rather than beside it — it is part of what
* the screen shows — so it is inside the canvas, scaled with it, wearing the
* dashboard's own look.
*/
export const RAIL_INSET = 72
/**
* The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components.
@@ -80,22 +100,43 @@ const DEFAULT_COLUMN_WIDTH =
*/
const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
const columnWidthOf = (dashboard: Dashboard) =>
(canvasOf(dashboard).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP
/**
* The part of the canvas an arrangement is laid out in.
*
* The margin off every edge, plus the rail's column when the panel carries
* one: a rail is part of the screen rather than something beside it, so the
* room it takes comes out of the canvas the same way the margin does. Which
* cell each widget sits in never changes — only how big a cell is.
*/
export const areaOf = (dashboard: Dashboard, rail = false) => {
const { width, height } = canvasOf(dashboard)
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
return {
left,
top: GRID_INSET,
width: Math.max(1, width - left - GRID_INSET),
height: Math.max(1, height - 2 * GRID_INSET),
}
}
const columnWidthOf = (dashboard: Dashboard, rail = false) =>
(areaOf(dashboard, rail).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const rowHeightOf = (dashboard: Dashboard) =>
Math.round(columnWidthOf(dashboard) / CELL_ASPECT)
export const rowHeightOf = (dashboard: Dashboard, rail = false) =>
Math.round(columnWidthOf(dashboard, rail) / CELL_ASPECT)
/** Distance between two row origins — what a widget's `y` is counted in. */
export const rowPitchOf = (dashboard: Dashboard) =>
rowHeightOf(dashboard) + GRID_GAP
export const rowPitchOf = (dashboard: Dashboard, rail = false) =>
rowHeightOf(dashboard, rail) + GRID_GAP
/** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard) =>
export const rowsOf = (dashboard: Dashboard, rail = false) =>
Math.max(
1,
Math.floor((canvasOf(dashboard).height + GRID_GAP) / rowPitchOf(dashboard)),
Math.floor(
(areaOf(dashboard, rail).height + GRID_GAP) / rowPitchOf(dashboard, rail),
),
)
/**
@@ -114,11 +155,14 @@ export const rowsOf = (dashboard: Dashboard) =>
export function CanvasSurface({
dashboard,
dots,
rail,
children,
}: {
dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode
children: (scale: number) => React.ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
@@ -140,6 +184,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
const root = useCanvasRoot(dashboard)
const area = areaOf(dashboard, Boolean(rail))
return (
<div ref={ref} className="relative size-full overflow-hidden">
@@ -165,20 +210,33 @@ export function CanvasSurface({
top: (box.height - height * scale) / 2,
transform: `scale(${scale})`,
transformOrigin: "top left",
// One dot per cell corner. The column pitch the grid snaps to
// is (width + gap) / columns; a row is one row plus the gap.
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${rowPitchOf(dashboard)}px`,
// One dot per cell corner, counted across the arranged area
// rather than the whole panel: the margin is not a cell.
"--dot-x": `${(area.width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${rowPitchOf(dashboard, Boolean(rail))}px`,
} as React.CSSProperties
}
>
{/* Full bleed, and under everything: a background covers the
panel, margin included. */}
<CanvasGround dashboard={dashboard} />
{/* Over the ground rather than under it: the dots are what is
being arranged against, and a wallpaper must not hide them. */}
{dots ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{children(scale)}
{rail}
<div
className="absolute"
style={{
left: area.left,
top: area.top,
width: area.width,
height: area.height,
}}
>
{/* Over the ground rather than under it: the dots are what is
being arranged against, and a wallpaper must not hide them. */}
{dots ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{children(scale)}
</div>
</div>
</LookProvider>
) : null}
@@ -293,11 +351,14 @@ export function DashboardView({
dashboard,
renderWidget,
stacked,
rail,
}: {
dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */
stacked?: boolean
/** Whether the panel carries a rail, which takes a column of the canvas. */
rail?: boolean
}) {
// ponytail: the first page only. A panel carries several whole dashboards
// — that is what the rail is for — so nothing writes a second page.
@@ -343,6 +404,7 @@ export function DashboardView({
widgets={widgets}
columns={columns}
stacked={stacked}
rail={rail}
renderWidget={renderWidget}
/>
</PaletteProvider>
@@ -363,12 +425,14 @@ function WidgetGrid({
widgets,
columns,
stacked,
rail,
renderWidget,
}: {
dashboard: Dashboard
widgets: WidgetDef[]
columns: number
stacked?: boolean
rail?: boolean
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
const { Frame } = useUi()
@@ -385,7 +449,7 @@ function WidgetGrid({
"--widget-cols": columns,
// The row height follows the canvas, so the CSS grid and the
// editor's grid library cannot drift apart.
"--row-height": `${rowHeightOf(dashboard)}px`,
"--row-height": `${rowHeightOf(dashboard, rail)}px`,
} as React.CSSProperties
}
>
@@ -403,6 +467,7 @@ function WidgetGrid({
<Frame
title={showTitle(widget) ? widget.title : undefined}
issue={widgetIssue(widget)}
scroll={scrolls(widget)}
>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</Frame>
@@ -4,10 +4,6 @@ import type { LinkProps } from "@tanstack/react-router"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useUi } from "./ui"
/** How much room the rail takes, canvas insets included. Mirrors the side
* panel's `27rem`: the button plus the gutters either side of it. */
export const RAIL_INSET = "4.5rem"
/**
* Switching between the dashboards one panel was assigned.
*
@@ -21,11 +21,14 @@ import { LookProvider } from "@/components/Dashboard/ui/core/look"
export function PanelSurface({
dashboard,
stacked,
rail,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
* its size, which reads as nothing at all. Stack it instead. */
stacked?: boolean
/** The way between this panel's dashboards, drawn on the canvas itself. */
rail?: React.ReactNode
}) {
return (
<div className="relative size-full">
@@ -34,8 +37,8 @@ export function PanelSurface({
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />}
<CanvasSurface dashboard={dashboard} rail={rail}>
{() => <DashboardView dashboard={dashboard} rail={Boolean(rail)} />}
</CanvasSurface>
)}
{/* Outside the canvas, so it needs the look stated for it. */}
@@ -67,6 +67,19 @@ export const fractionOf = (
export const showTitle = (widget: WidgetDef): boolean =>
config(widget).show_title !== false
/**
* Whether this widget's body may scroll.
*
* Only what is written or listed: a note is as long as it is, and an agenda
* has as many entries as the week does. Everything else is a picture drawn to
* fit the tile, and a picture that scrolls is one nobody can see the rest of —
* as well as a box that clips whatever glow or shadow crosses its edge.
*/
export const scrolls = (widget: WidgetDef): boolean =>
widget.type === "markdown" ||
widget.type === "agenda" ||
widget.type === "notification"
/** Radix hands back a string; the message wants whatever was configured. */
export function asOriginal(
selected: string,
@@ -36,6 +36,8 @@ export type FrameProps = {
issue?: string | null
/** Make this widget draggable in the editor. */
grip?: boolean
/** Let the body scroll. Only what is written or listed asks for it. */
scroll?: boolean
selected?: boolean
onClick?: React.MouseEventHandler<HTMLDivElement>
children: React.ReactNode
@@ -51,7 +51,7 @@ export function Button({
onClick={onClick}
>
<span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate">{children}</span>
<span className="gl-lift min-w-0 truncate">{children}</span>
</motion.button>
)
}
@@ -181,11 +181,14 @@ export function Select({
className="gl-field gl-pressable justify-between"
>
<span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate">
<span className="gl-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
<ChevronDown
className="gl-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
@@ -94,6 +94,7 @@ export function Frame({
title,
issue,
grip,
scroll,
selected,
onClick,
children,
@@ -121,7 +122,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null}
</div>
) : null}
<div className="dui-frame-body">{children}</div>
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead. */}
{!title && issue ? (
<div className="dui-frame-corner">
@@ -155,7 +158,7 @@ export function Rail({ entries }: RailProps) {
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"gl-surface gl-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
"gl-surface gl-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
@@ -95,8 +95,11 @@
/* --- surfaces ------------------------------------------------------------ */
/* No `position` of its own: this is worn by a tile, a rail and a notice alike,
and each of those is placed by whatever puts it there. Stating it here won
over the utility that placed them — an unlayered rule beats a layered one —
and collapsed the rail into the flow. */
.gl-surface {
position: relative;
border: 1px solid var(--gl-border);
border-radius: var(--gl-radius);
background: var(--gl-bg);
@@ -181,7 +184,12 @@
opacity: 0;
}
.gl-pressable > :not(.gl-glow) {
/* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.gl-switch-thumb,
.gl-segment-label,
.gl-lift {
position: relative;
z-index: 1;
}
@@ -316,7 +324,7 @@
}
.gl-segment[data-active] {
color: var(--foreground);
color: var(--primary-foreground);
}
.gl-segment-thumb {
@@ -324,13 +332,11 @@
inset: 0;
z-index: 0;
border-radius: 9999px;
background: var(--gl-bg-strong);
box-shadow: var(--gl-inset);
background: color-mix(in srgb, var(--primary) 85%, transparent);
box-shadow: var(--gl-glow), var(--gl-inset);
}
.gl-segment-label {
position: relative;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -80,7 +80,7 @@ export function Button({
onClick={onClick}
>
<Skin presses={presses} done={done} />
<span className="min-w-0 truncate">{children}</span>
<span className="m3-lift min-w-0 truncate">{children}</span>
</button>
)
}
@@ -207,11 +207,14 @@ export function Select({
disabled={disabled}
className="m3-field m3-pressable justify-between"
>
<span className="min-w-0 truncate">
<span className="m3-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
<ChevronDown
className="m3-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
@@ -58,6 +58,7 @@ export function Frame({
title,
issue,
grip,
scroll,
selected,
onClick,
children,
@@ -85,7 +86,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null}
</div>
) : null}
<div className="dui-frame-body">{children}</div>
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead — a widget
drawn without its title still says when it is mis-wired. */}
{!title && issue ? (
@@ -121,7 +124,7 @@ export function Rail({ entries }: RailProps) {
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"m3-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
"m3-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
// More dashboards than the column is tall still scroll, but no bar is
// ever drawn: a wall panel is swiped, and there is no room for one.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
@@ -100,7 +100,12 @@
background: currentColor;
}
.m3-pressable > :not(.m3-state):not(.m3-ripple) {
/* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.m3-switch-thumb,
.m3-segment-label,
.m3-lift {
position: relative;
z-index: 1;
}
@@ -233,7 +238,7 @@
}
.m3-segment[data-active] {
color: var(--card-foreground);
color: var(--primary-foreground);
}
.m3-segment-thumb {
@@ -241,12 +246,10 @@
inset: 0;
z-index: 0;
border-radius: 9999px;
background: var(--m3-sc);
background: var(--primary);
}
.m3-segment-label {
position: relative;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -76,7 +76,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
switch: "Switch",
slider: "Slider",
input: "Input",
dropdown: "Dropdown",
dropdown: "Selector",
color: "Colour",
}
+21 -17
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail, RAIL_INSET } from "@/components/Dashboard/PanelRail"
import { PanelRail } from "@/components/Dashboard/PanelRail"
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import {
dashboardQueryOptions,
@@ -85,25 +85,29 @@ function PanelRoute() {
root.className,
stacked ? "overflow-y-auto" : "overflow-hidden",
)}
style={{
...root.style,
...(rail ? { paddingLeft: RAIL_INSET } : {}),
}}
style={root.style}
>
<LookProvider dashboard={dashboard as Dashboard | undefined}>
{rail && current ? (
<PanelRail
dashboards={dashboards}
current={current}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
search: { d: name },
})}
/>
) : null}
{dashboard ? (
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
<PanelSurface
dashboard={dashboard as Dashboard}
stacked={stacked}
// Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too.
rail={
rail && current ? (
<PanelRail
dashboards={dashboards}
current={current}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
search: { d: name },
})}
/>
) : null
}
/>
) : null}
</LookProvider>
</main>