From 518231aa39fd1dac913750340cc770fae805ab8c Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 31 Aug 2026 19:04:20 +0200 Subject: [PATCH] Touch is a panel setting, and the rail grows with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It described the wrong object. A dashboard is a document that may hang on a hallway tablet and in a desk browser at the same time, and only one of those has fingers on it — so the flag moves off `DashboardDef.settings` and onto `PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes it as an argument rather than reading the document, and `/panel/{id}` is the only surface with a panel to ask. Dropping the message binding with it is deliberate: nothing drove it, and a flow deciding whether a screen has fingers on it was never the point. A stored `settings.touch` is inert rather than migrated, which `_check_settings` skipping unknown names already guaranteed. The rail was the other half. It had no touch behaviour at all and its 40px buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}` in `ui/core/core.css` spends the padding and the gap on the buttons instead, so they reach the 44px target and the rail comes out taller at exactly the same width — `RAIL_INSET` never moves, and the arrangement under it does not either. Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon` now, so the dialog draws each assigned dashboard's rail glyph beside its checkbox. `initials()` went from three identical copies in the looks to one in `Dashboard/icons.ts`, so the dialog and the rail fall back the same way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ --- backend/fluksio/flow/dashboards.py | 6 +- backend/fluksio/flow/panels.py | 5 + backend/tests/api/routes/test_panels.py | 32 +++++++ docs/interface/dashboards.md | 18 ++-- frontend/src/client/schemas.gen.ts | 10 ++ frontend/src/client/sdk.gen.ts | 23 ++++- frontend/src/client/types.gen.ts | 8 ++ .../components/Dashboard/DashboardView.tsx | 7 +- .../src/components/Dashboard/PanelSurface.tsx | 7 +- .../src/components/Dashboard/PanelsDialog.tsx | 38 +++++++- frontend/src/components/Dashboard/icons.ts | 14 +++ frontend/src/components/Dashboard/panels.tsx | 24 ----- .../src/components/Dashboard/settings.tsx | 7 -- .../src/components/Dashboard/ui/core/core.css | 27 ++++++ .../src/components/Dashboard/ui/core/look.tsx | 25 +++-- .../Dashboard/ui/fluksio/Surfaces.tsx | 14 +-- .../Dashboard/ui/glass/Surfaces.tsx | 14 +-- .../Dashboard/ui/material/Surfaces.tsx | 14 +-- frontend/src/routes/panel.$id.tsx | 13 ++- frontend/tests/panel.spec.ts | 93 ++++++++++++++++++- frontend/tests/persistence.spec.ts | 2 +- frontend/tests/widgets.spec.ts | 31 ------- 22 files changed, 308 insertions(+), 124 deletions(-) diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 314b5c6..b1e1188 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -143,8 +143,6 @@ SETTING_DTYPES: dict[str, str] = { # An image drawn under the widgets, by URL. A flow publishing to it is what # a wallpaper that changes looks like here. "background": "str", - # Bigger controls and no hover states, for a panel that is touched. - "touch": "bool", } @@ -525,6 +523,9 @@ class DashboardSummary(BaseModel): name: str title: str = "" + #: The glyph this dashboard draws on a panel's rail, so a list can show it + #: without reading every document. + icon: str = "" widget_count: int = 0 has_draft: bool = False #: Of the working copy, so publishing from a list needs no second read. @@ -586,6 +587,7 @@ class DashboardStore: DashboardSummary( name=defn.name, title=defn.title, + icon=defn.icon, widget_count=len(defn.widgets), has_draft=defn.has_draft, version=defn.version, diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py index 1ce25bf..9c999c5 100644 --- a/backend/fluksio/flow/panels.py +++ b/backend/fluksio/flow/panels.py @@ -31,6 +31,11 @@ class PanelDef(BaseModel): #: rail follows this order. A name that no longer resolves is simply a #: dashboard someone deleted; the panel skips it. dashboards: list[str] = Field(default_factory=list) + #: Bigger controls, a bigger rail and no hover states, for a screen that is + #: touched rather than pointed at. It belongs to the device rather than to + #: any dashboard: the same dashboard may hang on a hallway tablet and on a + #: desk browser, and only one of them has fingers on it. + touch: bool = False #: Which generation of credential this panel honours. A token names the #: nonce it was minted at, so bumping this refuses the screen currently #: hanging here and leaves the panel, its dashboards and their arrangement diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index 8f69ffc..331f534 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -86,6 +86,38 @@ def test_assign_and_read_back( ) +def test_touch_belongs_to_the_panel( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """Whether a screen is touched is a fact about the screen, not the document. + + The same dashboard may hang on a hallway tablet and on a desk browser, so + the flag rides on the panel and each one answers for itself. + """ + client.post(f"{DASHBOARDS}/shared", headers=superuser_token_headers) + _panels( + client, + superuser_token_headers, + { + "panels": [ + {"id": "wall", "dashboards": ["shared"], "touch": True}, + {"id": "desk", "dashboards": ["shared"]}, + ] + }, + ) + + by_id = { + panel["id"]: panel + for panel in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[ + "panels" + ] + } + assert by_id["wall"]["touch"] is True + # Absent is pointed at, which is what a panels file written before this + # field existed comes back as. + assert by_id["desk"]["touch"] is False + + def test_duplicate_panel_is_refused( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 0ee0ccc..daaab5f 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -155,7 +155,6 @@ set them. | **Theme** | `System`, `Light` or `Dark` | a `str` message | | **Palette** | the dashboard's colours, in order | a `list` message | | **Background** | the URL of an image | a `str` message | -| **Touch** | touch friendly on or off | a `bool` message | | **Lock** | read-only on or off | a `bool` message | They all work the same way, and both halves are optional: @@ -219,11 +218,6 @@ paintable as a reading. An image drawn under the widgets, covering the canvas. It replaces the ground the Glass look brings with it. Bound to a message, a flow decides the picture. -### Touch - -Bigger controls, and nothing that only happens on hover. A phone gets this -anyway, from its own width; a wall panel has no way to say so for itself. - ### Lock Lock is a read-only *surface*, not a permission. The controls stay visible, @@ -243,10 +237,16 @@ A wall tablet has no keyboard, so it pairs. 1. **Dashboards → Panels**, add a panel named after where it hangs, and tick the dashboards it shows. More than one and the screen draws a rail to switch - between them. -2. Point the device's browser at the link the dialog shows. The device then + between them, with each dashboard's own icon on it. +2. Tick **Touch friendly** if the screen is touched rather than pointed at. + Controls and the rail grow to a finger's size — the rail gets taller without + taking a wider column, so the arrangement does not move. It sits here rather + than on a dashboard because it describes the screen: the same dashboard may + also be open in a browser with a mouse. A phone gets it anyway, from its own + width. +3. Point the device's browser at the link the dialog shows. The device then displays a six-character code. -3. Type that code into the same panel's **Pair device** field. The line under +4. Type that code into the same panel's **Pair device** field. The line under it names what is holding the code. Check it is the screen you just hung, because approving adopts whatever answered. The screen picks the credential up within a few seconds and never asks again. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index af1fef2..69e9df2 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -556,6 +556,11 @@ export const DashboardSummarySchema = { title: 'Title', default: '' }, + icon: { + type: 'string', + title: 'Icon', + default: '' + }, widget_count: { type: 'integer', title: 'Widget Count', @@ -2355,6 +2360,11 @@ export const PanelDefSchema = { type: 'array', title: 'Dashboards' }, + touch: { + type: 'boolean', + title: 'Touch', + default: false + }, nonce: { type: 'integer', title: 'Nonce', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 49af8a4..3ef869d 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -2159,6 +2159,27 @@ export class RunsService { }); } + /** + * Retry Run + * Run the same thing again, as a new run pointing back at this one. + * @param data The data for the request. + * @param data.runId + * @returns fluksio__api__routes__runs__RunRow Successful Response + * @throws ApiError + */ + public static retryRun(data: RunsRetryRunData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/runs/{run_id}/retry', + path: { + run_id: data.runId + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Read Metrics * One metric's series, in step order — or every one of them, unnamed. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 4a611d9..428c47d 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -154,6 +154,7 @@ export type DashboardsPublic = { export type DashboardSummary = { name: string; title?: string; + icon?: string; widget_count?: number; has_draft?: boolean; version?: number; @@ -848,6 +849,7 @@ export type PanelDef = { id: string; title?: string; dashboards?: Array<(string)>; + touch?: boolean; nonce?: number; }; @@ -1872,6 +1874,12 @@ export type RunsCancelRunData = { export type RunsCancelRunResponse = (fluksio__api__routes__runs__RunRow); +export type RunsRetryRunData = { + runId: string; +}; + +export type RunsRetryRunResponse = (fluksio__api__routes__runs__RunRow); + export type RunsReadMetricsData = { name?: string; runId: string; diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 7d2a72d..3e294b7 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -156,6 +156,7 @@ export function CanvasSurface({ dashboard, dots, rail, + touch, children, }: { dashboard: Dashboard @@ -163,6 +164,8 @@ export function CanvasSurface({ dots?: boolean /** The dashboard-switching rail, drawn on the panel rather than beside it. */ rail?: React.ReactNode + /** Whether the panel this canvas hangs on is touched rather than pointed at. */ + touch?: boolean children: (scale: number) => React.ReactNode }) { const ref = useRef(null) @@ -183,7 +186,7 @@ export function CanvasSurface({ const { width, height } = canvasOf(dashboard) const scale = Math.min(box.width / width, box.height / height) - const root = useCanvasRoot(dashboard) + const root = useCanvasRoot(dashboard, touch) const area = areaOf(dashboard, Boolean(rail)) return ( @@ -191,7 +194,7 @@ export function CanvasSurface({ {/* Measured first: a guessed scale would place the whole panel once and then move it. */} {scale > 0 ? ( - +
@@ -37,12 +40,12 @@ export function PanelSurface({ ) : ( // The panel's own surface, scaled to fit. No dots: nothing is being // arranged here. - + {() => } )} {/* Outside the canvas, so it needs the look stated for it. */} - +
diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index ea46f2a..fb79103 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -9,6 +9,7 @@ import { type PanelsConfig, PanelsService, } from "@/client" +import { initials, resolveIcon } from "@/components/Dashboard/icons" import { dashboardsQueryOptions, panelsQueryOptions, @@ -26,6 +27,7 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { cn } from "@/lib/utils" @@ -120,6 +122,7 @@ export function PanelsDialog() { dashboards={known.map((dashboard) => ({ name: dashboard.name, title: dashboard.title || dashboard.name, + icon: dashboard.icon ?? "", }))} onChange={(next) => replace(panel.id, next)} onRemove={() => @@ -196,7 +199,7 @@ function PanelRow({ * links — worth seeing — with the writes turned off rather than a 403. */ canEdit: boolean - dashboards: { name: string; title: string }[] + dashboards: { name: string; title: string; icon: string }[] onChange: (next: PanelDef) => void onRemove: () => void }) { @@ -294,6 +297,25 @@ function PanelRow({ + + {dashboards.length === 0 ? (

No dashboards to assign yet. @@ -303,6 +325,10 @@ function PanelRow({ {dashboards.map((dashboard) => { const position = assigned.indexOf(dashboard.name) const id = `assign-${panel.id}-${dashboard.name}` + // What this dashboard draws on the rail. Shown rather than set: the + // icon belongs to the document, and this dialog has no draft to put + // a change into. + const Glyph = resolveIcon(dashboard.icon) return (

- Touch friendly - - setSetting("touch", { ...touch, value }) - } - /> -
- setSetting("touch", setting)} - /> - - /** The settings this build actually wires up. */ @@ -119,11 +117,6 @@ export function useDashboardBackground( return typeof value === "string" ? value.trim() : "" } -/** Whether this dashboard is drawn for a finger rather than a pointer. */ -export const useDashboardTouch = ( - dashboard: DashboardDef_Output | undefined, -): boolean => useSetting(dashboard, "touch") === true - /** * The class that themes a dashboard's own surface, or `""` to follow the app. * diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index 478f97e..b591396 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -56,6 +56,33 @@ line-height: 1.25; } +/* + * The rail grows into the column it already has rather than taking a wider one. + * + * `RAIL_INSET` reserves the same 72px either way — the arrangement must not + * move because a screen was told it has fingers on it — so the padding and the + * gap pay for the buttons, and the rail comes out taller rather than wider. + * 48px of width less two 2px margins is the 44px target the guidelines ask for. + * + * Geometry here, paint in each look: the sizes are Tailwind literals on the + * three `Rail`s, which this beats because the file lands unlayered. + */ +[data-touch] .dui-rail { + padding: 0.125rem; + gap: 0.125rem; +} + +[data-touch] .dui-rail-item { + width: 2.75rem; + height: 2.75rem; + font-size: 0.875rem; +} + +[data-touch] .dui-rail-item svg { + width: 1.5rem; + height: 1.5rem; +} + /* * Pulled up half a step, so the title reads from the centre of the corner * radius rather than from below it — and the body, which is what anyone is diff --git a/frontend/src/components/Dashboard/ui/core/look.tsx b/frontend/src/components/Dashboard/ui/core/look.tsx index ef87677..be3290f 100644 --- a/frontend/src/components/Dashboard/ui/core/look.tsx +++ b/frontend/src/components/Dashboard/ui/core/look.tsx @@ -1,9 +1,10 @@ /** * Which look is being drawn, and what the canvas it is drawn on carries. * - * A dashboard states its look, its colours and whether it is touched on one - * element — the canvas root — and everything below reads them from there: - * the tokens by inheritance, the look and the touch flag through this context. + * A dashboard states its look and its colours, and the panel it hangs on says + * whether it is touched, on one element — the canvas root — and everything + * below reads them from there: the tokens by inheritance, the look and the + * touch flag through this context. * * The style is carried in the context as well as on the element, because a * menu is portalled to `body` and lands outside the canvas. A surface that @@ -18,7 +19,6 @@ import { useDashboardLook, useDashboardPalette, useDashboardTheme, - useDashboardTouch, } from "../../settings" import { rolesOf, tokenStyle } from "./theme" @@ -47,13 +47,21 @@ export const useLook = () => useContext(LookContext) * for a dashboard that follows the device: the sets state their own colours * per theme, and a chart canvas has to be told which one it is drawing in. * Restating the app's own resolved theme changes nothing when they agree. + * + * `touch` is passed in rather than read off the document, because it belongs to + * the screen rather than to the dashboard: the same document may hang on a + * hallway tablet and on a desk browser. Only `/panel/{id}` has a panel to ask, + * so everywhere else takes the default — a phone still gets the finger-sized + * ladder from the width query in `core.css`. */ -export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) { +export function useCanvasRoot( + dashboard: DashboardDef_Output | undefined, + touch = false, +) { const { resolvedTheme } = useTheme() const stated = useDashboardTheme(dashboard) const roles = rolesOf(useDashboardPalette(dashboard)) const look = useDashboardLook(dashboard) - const touch = useDashboardTouch(dashboard) return { className: stated || (resolvedTheme === "dark" ? "dark" : "light"), style: (roles ? tokenStyle(roles) : {}) as React.CSSProperties, @@ -72,12 +80,15 @@ export function useCanvasRoot(dashboard: DashboardDef_Output | undefined) { */ export function LookProvider({ dashboard, + touch, children, }: { dashboard: DashboardDef_Output | undefined + /** Whether the panel this is drawn on is touched. See `useCanvasRoot`. */ + touch?: boolean children: React.ReactNode }) { - const root = useCanvasRoot(dashboard) + const root = useCanvasRoot(dashboard, touch) return ( {entry.active ? ( diff --git a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx index 535781b..74d20c7 100644 --- a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx +++ b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx @@ -15,7 +15,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import { ICONS } from "../../icons" +import { ICONS, initials } from "../../icons" import type { BackdropProps, FrameProps, @@ -109,21 +109,13 @@ export function Frame({ ) } -/** Two letters off the title, so a rail of four reads as four different things. */ -function initials(label: string): string { - const words = label.split(/[\s_-]+/).filter(Boolean) - if (words.length === 0) return "?" - if (words.length === 1) return words[0].slice(0, 2).toUpperCase() - return (words[0][0] + words[1][0]).toUpperCase() -} - export function Rail({ entries }: RailProps) { return (