@k2b/cloud 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,13 +6,14 @@ import {
6
6
  serializeAppWorkspaceLayoutState,
7
7
  useLocale,
8
8
  } from "@k2b/ui";
9
- import { createEffect, onCleanup, onMount } from "solid-js";
9
+ import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
10
10
  import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_internal/app-workspace-state";
11
11
  import { registerContextAwareCommand } from "../browser/command-bridge";
12
12
  import { attachCommandShortcuts } from "../browser/command-shortcuts";
13
13
  import { navigationSearchItems } from "../browser/navigation-search";
14
14
  import { importOnDemand } from "../browser/reload";
15
15
  import { registerGlobalSearchHost } from "../browser/search-bridge";
16
+ import { attachShellFocusControls, readShellFocus, SHELL_FOCUS_CHANGE_EVENT, toggleShellFocus } from "../browser/shell-focus";
16
17
  import {
17
18
  ANNOUNCEMENTS_COOKIE,
18
19
  ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
@@ -37,6 +38,8 @@ export type ShellRuntimeProps = {
37
38
  help?: { documents: readonly HelpDocumentManifest[]; pageBase: string };
38
39
  /** Layout pages outside focus mode: the search host, Cmd/Ctrl+K, the Help command (Shift+/) and command shortcuts. */
39
40
  commands?: { searchResources: boolean };
41
+ /** Signed-in Layout pages: the sidebar buttons, Cmd/Ctrl+\ and the left-edge drag hide and show the app bar and the app's navigation. */
42
+ focus?: boolean;
40
43
  /** Signed-in pages: refresh this browser's push endpoint. */
41
44
  push?: boolean;
42
45
  /** Announcements the person has not seen yet; a dialog shows them once. */
@@ -95,6 +98,38 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
95
98
  });
96
99
  });
97
100
  }
101
+ if (props.focus) {
102
+ const [hidden, setHidden] = createSignal(false);
103
+ const [available, setAvailable] = createSignal(false);
104
+ onMount(() => {
105
+ setHidden(readShellFocus());
106
+ const media = window.matchMedia("(min-width: 768px)");
107
+ const resize = () => setAvailable(media.matches);
108
+ const change = (event: Event) => setHidden((event as CustomEvent<{ hidden: boolean }>).detail.hidden);
109
+ const dispose = attachShellFocusControls();
110
+ resize();
111
+ media.addEventListener("change", resize);
112
+ window.addEventListener(SHELL_FOCUS_CHANGE_EVENT, change);
113
+ onCleanup(() => {
114
+ dispose();
115
+ media.removeEventListener("change", resize);
116
+ window.removeEventListener(SHELL_FOCUS_CHANGE_EVENT, change);
117
+ });
118
+ });
119
+ createEffect(() => {
120
+ if (!available()) return;
121
+ onCleanup(
122
+ registerContextAwareCommand({
123
+ id: "cloud.shell.focus",
124
+ title: hidden() ? t().showSidebar : t().hideSidebar,
125
+ description: t().sidebarCommandDescription,
126
+ icon: hidden() ? "ti ti-layout-sidebar-left-expand" : "ti ti-layout-sidebar-left-collapse",
127
+ shortcut: "mod+\\",
128
+ action: toggleShellFocus,
129
+ }),
130
+ );
131
+ });
132
+ }
98
133
  if (props.commands) {
99
134
  const host = createGlobalSearchHost(searchItems, props.commands.searchResources);
100
135
  onMount(() => onCleanup(registerGlobalSearchHost(host.open, host.dispose)));
@@ -93,7 +93,7 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
93
93
  onClick={open}
94
94
  aria-busy={action.busy() ? "true" : undefined}
95
95
  >
96
- <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-base`} aria-hidden="true" />
96
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-xl`} aria-hidden="true" />
97
97
  </Tooltip.Trigger>
98
98
  );
99
99
  }
@@ -65,6 +65,11 @@ export const platformMessages = i18n.define({
65
65
  pageUpDescription: "Go to the page above this one, as you left it in this tab.",
66
66
  openHelp: "Open help",
67
67
  helpShortcut: "Help (Shift+/)",
68
+ hideSidebar: "Hide sidebar",
69
+ showSidebar: "Show sidebar",
70
+ hideSidebarShortcut: "Hide sidebar (⌘/Ctrl+\\)",
71
+ showSidebarShortcut: "Show sidebar (⌘/Ctrl+\\)",
72
+ sidebarCommandDescription: "Hide or show the app bar and the app's navigation.",
68
73
  signIn: "Sign in",
69
74
  menu: "Menu",
70
75
  search: "Search",
@@ -132,6 +137,11 @@ export const platformMessages = i18n.define({
132
137
  pageUpDescription: "Zur Seite darüber, so wie du sie in diesem Tab verlassen hast.",
133
138
  openHelp: "Hilfe öffnen",
134
139
  helpShortcut: "Hilfe (Umschalt+/)",
140
+ hideSidebar: "Seitenleiste ausblenden",
141
+ showSidebar: "Seitenleiste zeigen",
142
+ hideSidebarShortcut: "Seitenleiste ausblenden (⌘/Strg+\\)",
143
+ showSidebarShortcut: "Seitenleiste zeigen (⌘/Strg+\\)",
144
+ sidebarCommandDescription: "App-Leiste und Navigation der App aus- oder einblenden.",
135
145
  signIn: "Anmelden",
136
146
  menu: "Menü",
137
147
  search: "Suchen",
@@ -0,0 +1,83 @@
1
+ import { AppWorkspace } from "@k2b/ui";
2
+ import { Hono } from "hono";
3
+ import { Dynamic } from "solid-js/web";
4
+ import { defineApp } from "../_internal/define-app";
5
+ import { person } from "../_internal/define-app.test-fixture";
6
+ import type { User } from "../contracts/shared";
7
+ import Layout from "./Layout";
8
+ import type { RuntimeContext } from "./runtime";
9
+
10
+ const probe = defineApp({
11
+ id: "shell-focus-probe",
12
+ name: "Focus probe",
13
+ icon: "ti ti-layout",
14
+ description: "Shell focus browser fixture",
15
+ baseUrl: "http://shell-focus-probe:3000",
16
+ routes: ["/app/focus"],
17
+ });
18
+ type Environment = { Variables: { runtime: RuntimeContext; user: User } };
19
+ export const server = new Hono<Environment>()
20
+ .use(async (c, next) => {
21
+ c.set("runtime", {
22
+ apps: [
23
+ {
24
+ id: "shell-focus-probe",
25
+ name: "Focus probe",
26
+ description: "Shell focus browser fixture",
27
+ icon: "ti ti-layout",
28
+ routes: ["/app/focus"],
29
+ nav: { href: "/app/focus", section: "primary" },
30
+ },
31
+ ],
32
+ });
33
+ c.set("user", person);
34
+ await next();
35
+ })
36
+ .get(
37
+ "/app/focus",
38
+ ...probe.ssr<Environment>((c) => () => (
39
+ <Layout c={c} fullWidth fullPage title={[{ title: "Focus probe", href: "/app/focus" }, { title: "Workspace" }]}>
40
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
41
+ <AppWorkspace.Sidebar id="focus-navigation" label="Navigation">
42
+ <AppWorkspace.SidebarDesktop>
43
+ <AppWorkspace.SidebarItem href="/app/focus" active>
44
+ Workspace
45
+ </AppWorkspace.SidebarItem>
46
+ </AppWorkspace.SidebarDesktop>
47
+ </AppWorkspace.Sidebar>
48
+ <AppWorkspace.Content>
49
+ <AppWorkspace.Main>
50
+ <textarea aria-label="Editor" />
51
+ </AppWorkspace.Main>
52
+ </AppWorkspace.Content>
53
+ </AppWorkspace>
54
+ </Layout>
55
+ )),
56
+ )
57
+ // Grids wraps Main in an island inside Content and gives it a navigation pane.
58
+ .get(
59
+ "/app/focus/panes",
60
+ ...probe.ssr<Environment>((c) => () => (
61
+ <Layout c={c} fullWidth fullPage title={[{ title: "Focus probe", href: "/app/focus" }, { title: "Pages" }]}>
62
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
63
+ <AppWorkspace.Sidebar id="focus-navigation" label="Navigation">
64
+ <AppWorkspace.SidebarDesktop>
65
+ <AppWorkspace.SidebarItem href="/app/focus/panes" active>
66
+ Pages
67
+ </AppWorkspace.SidebarItem>
68
+ </AppWorkspace.SidebarDesktop>
69
+ </AppWorkspace.Sidebar>
70
+ <AppWorkspace.Content>
71
+ <Dynamic component="solid-island">
72
+ <AppWorkspace.Main mobilePane="main" scroll={false}>
73
+ <AppWorkspace.MainPane id="focus-pages" label="Pages" surface="navigation" defaultSize={280}>
74
+ Page list
75
+ </AppWorkspace.MainPane>
76
+ <div>Page</div>
77
+ </AppWorkspace.Main>
78
+ </Dynamic>
79
+ </AppWorkspace.Content>
80
+ </AppWorkspace>
81
+ </Layout>
82
+ )),
83
+ );
@@ -79,8 +79,8 @@ html:has(.cloud-app-canvas[data-layout-full-page="true"]) {
79
79
  /* The authenticated rail is part of the canvas. Keeping it transparent also
80
80
  covers shell-only routes such as 404 pages that have no app workspace. */
81
81
  .cloud-app-canvas > .layout-rail {
82
- width: calc(2.5rem + var(--ui-space-shell));
83
- margin-inline-start: calc(-1 * var(--ui-space-shell));
82
+ width: calc(3.5rem + env(safe-area-inset-left, 0px));
83
+ padding-left: env(safe-area-inset-left, 0px);
84
84
  background-color: transparent !important;
85
85
  background-image: none !important;
86
86
  }
@@ -294,11 +294,13 @@ solid-island {
294
294
  }
295
295
 
296
296
  /* Cloud has three shell modes. Phones, below md, get the header and no rail.
297
- From md, signed-in people get the rail instead of the header: up to 2xl the
298
- app area meets the window's top, right and bottom edges, and from 2xl it is
299
- a card with the shell spacing. Anonymous pages keep the header and the card
300
- from md. */
301
- @media (max-width: 767px) {
297
+ From md, signed-in people get the 56 px rail instead of the header: up to
298
+ 1023 px the app area meets the window's top, right and bottom edges, and
299
+ from 1024 px it is a card beside the rail. Anonymous pages keep the header
300
+ and the card from md. Each query is the exact opposite of the next one, so
301
+ every width, also a fractional one under browser zoom, falls into exactly
302
+ one mode. */
303
+ @media not all and (min-width: 768px) {
302
304
  .cloud-app-canvas {
303
305
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
304
306
  }
@@ -360,35 +362,162 @@ solid-island {
360
362
  The rail and the app area reach the window's edges also where the installed
361
363
  app reports safe areas, such as the status bar and the home indicator. Their
362
364
  own surfaces fill the insets, and their content stays inside them. */
363
- @media (min-width: 768px) and (max-width: 1535px) {
365
+ @media (min-width: 768px) {
366
+ @media not all and (min-width: 1024px) {
367
+ .cloud-app-canvas[data-layout-authenticated] {
368
+ padding: 0;
369
+ }
370
+ .cloud-app-canvas[data-layout-authenticated] > .layout-rail {
371
+ padding-top: env(safe-area-inset-top, 0px);
372
+ padding-bottom: calc(var(--ui-space-shell) + env(safe-area-inset-bottom, 0px));
373
+ }
374
+ .cloud-app-canvas[data-layout-authenticated] > .layout-shell-content {
375
+ gap: 0;
376
+ padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 0;
377
+ background: var(--k2b-surface);
378
+ }
379
+ /* A regular page scrolls `main`, so its end clears the home indicator. A
380
+ workspace reaches the bottom edge and clears it inside its own surfaces. */
381
+ .cloud-app-canvas[data-layout-authenticated] .layout-content-main:not(:has(.k2b-app-workspace)) {
382
+ padding-bottom: env(safe-area-inset-bottom, 0px);
383
+ }
384
+ .cloud-app-canvas[data-layout-authenticated] .layout-content-main .k2b-app-workspace:not(.k2b-app-workspace *) {
385
+ --k2b-workspace-radius: 0px;
386
+ --k2b-workspace-inset-bottom: env(safe-area-inset-bottom, 0px);
387
+ border: 0;
388
+ box-shadow: none;
389
+ }
390
+ .cloud-app-canvas[data-layout-authenticated] > .layout-shell-content > :not(.layout-content-main) .k2b-notice-card {
391
+ border: 0;
392
+ border-radius: 0;
393
+ }
394
+ }
395
+ }
396
+
397
+ /* Signed-in card: from 1024 px the app area is a card on the app canvas beside
398
+ the rail, as on laptops. Its spacing and radius grow with the window, from
399
+ 4 to 8 px and from 10 to 14 px at 1440 px, the wide card from there on, in
400
+ whole pixels, so the card and its columns keep whole widths. The
401
+ rail starts at the window's left edge and moves down with the card. In the
402
+ installed app the card and the rail stay below the status bar; the card keeps
403
+ its spacing at the bottom, and its surfaces keep their content above the
404
+ home indicator, as in the flush range. */
405
+ @media (min-width: 1024px) {
364
406
  .cloud-app-canvas[data-layout-authenticated] {
365
- padding: 0;
407
+ --cloud-card-inset: round(clamp(0.25rem, calc(0.25rem + (100vw - 64rem) / 104), 0.5rem), 1px);
408
+ --cloud-card-radius: round(clamp(0.625rem, calc(0.625rem + (100vw - 64rem) / 104), 0.875rem), 1px);
409
+ --cloud-card-inset-bottom: max(0px, env(safe-area-inset-bottom, 0px) - var(--cloud-card-inset));
410
+ padding: max(var(--cloud-card-inset), env(safe-area-inset-top, 0px)) max(var(--cloud-card-inset), env(safe-area-inset-right, 0px))
411
+ var(--cloud-card-inset) 0;
366
412
  }
367
413
  .cloud-app-canvas[data-layout-authenticated] > .layout-rail {
368
- width: calc(3rem + env(safe-area-inset-left, 0px));
369
- margin-inline-start: 0;
370
- padding: env(safe-area-inset-top, 0px) 0 calc(var(--ui-space-shell) + env(safe-area-inset-bottom, 0px))
371
- env(safe-area-inset-left, 0px);
414
+ padding-bottom: var(--cloud-card-inset-bottom);
372
415
  }
373
416
  .cloud-app-canvas[data-layout-authenticated] > .layout-shell-content {
374
- gap: 0;
375
- padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 0;
376
- background: var(--k2b-surface);
417
+ gap: var(--cloud-card-inset);
377
418
  }
378
- /* A regular page scrolls `main`, so its end clears the home indicator. A
379
- workspace reaches the bottom edge and clears it inside its own surfaces. */
380
419
  .cloud-app-canvas[data-layout-authenticated] .layout-content-main:not(:has(.k2b-app-workspace)) {
381
- padding-bottom: env(safe-area-inset-bottom, 0px);
420
+ padding-bottom: var(--cloud-card-inset-bottom);
382
421
  }
383
422
  .cloud-app-canvas[data-layout-authenticated] .layout-content-main .k2b-app-workspace:not(.k2b-app-workspace *) {
384
- --k2b-workspace-radius: 0px;
385
- --k2b-workspace-inset-bottom: env(safe-area-inset-bottom, 0px);
386
- border: 0;
387
- box-shadow: none;
423
+ --k2b-workspace-radius: var(--cloud-card-radius);
424
+ --k2b-workspace-inset-bottom: var(--cloud-card-inset-bottom);
425
+ }
426
+ }
427
+
428
+ /* Focus: the app bar and the outermost workspace's navigation hide together
429
+ (Cmd/Ctrl+\ or the sidebar buttons). The canvas carries the state from the
430
+ `cloud_shell_focus` cookie, so the server renders the same first frame.
431
+ Phones ignore it. The app area takes the app bar's place and starts at the
432
+ card spacing, or at the window's edge where it is flush. The button that
433
+ shows them again floats at the app area's bottom start corner; on touch
434
+ screens a drag from the 18 px strip at the left edge does the same. From
435
+ 768 to 1023 px the navigation is already an overlay behind the
436
+ workspace head's button, so there only the app bar hides. */
437
+ .cloud-shell-show,
438
+ .cloud-shell-reveal {
439
+ display: none;
440
+ }
441
+
442
+ @media (min-width: 768px) {
443
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .layout-rail {
444
+ display: none;
445
+ }
446
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show {
447
+ position: absolute;
448
+ left: calc(env(safe-area-inset-left, 0px) + 0.75rem);
449
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
450
+ z-index: 15;
451
+ display: flex;
452
+ }
453
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show .k2b-icon-button {
454
+ box-shadow: var(--k2b-shadow-float);
455
+ }
456
+ }
457
+
458
+ @media (min-width: 768px) and (pointer: coarse) {
459
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .cloud-shell-reveal {
460
+ position: fixed;
461
+ top: 0;
462
+ bottom: 0;
463
+ left: 0;
464
+ z-index: 16;
465
+ display: block;
466
+ width: calc(1.125rem + env(safe-area-inset-left, 0px));
467
+ touch-action: pan-y;
468
+ }
469
+ }
470
+
471
+ @media (min-width: 768px) {
472
+ @media not all and (min-width: 1024px) {
473
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .layout-shell-content {
474
+ padding-left: env(safe-area-inset-left, 0px);
475
+ }
476
+ }
477
+ }
478
+
479
+ @media (min-width: 1024px) {
480
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] {
481
+ padding-left: max(var(--cloud-card-inset), env(safe-area-inset-left, 0px));
482
+ }
483
+ .cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show {
484
+ left: calc(max(var(--cloud-card-inset), env(safe-area-inset-left, 0px)) + 0.75rem);
485
+ bottom: calc(var(--cloud-card-inset) + var(--cloud-card-inset-bottom) + 0.75rem);
486
+ }
487
+ /* The navigation leaves like a hidden sidebar: its track, its resize
488
+ handle, and navigation panes go, and the work area rounds its start
489
+ corners with the card. An island may wrap the sidebar or Main, as in
490
+ Grids. */
491
+ .cloud-app-canvas[data-shell-focus] .k2b-app-workspace:not(.k2b-app-workspace *) {
492
+ --k2b-workspace-start-radius: var(--k2b-workspace-inner-radius);
388
493
  }
389
- .cloud-app-canvas[data-layout-authenticated] > .layout-shell-content > :not(.layout-content-main) .k2b-notice-card {
390
- border: 0;
391
- border-radius: 0;
494
+ .cloud-app-canvas[data-shell-focus]
495
+ .k2b-app-workspace:not(.k2b-app-workspace *)
496
+ > :is(.k2b-app-workspace__sidebar, .k2b-app-workspace__resize[data-app-workspace-resize="sidebar"]),
497
+ .cloud-app-canvas[data-shell-focus]
498
+ .k2b-app-workspace:not(.k2b-app-workspace *)
499
+ > :is(solid-island, solid-client)
500
+ > :is(.k2b-app-workspace__sidebar, .k2b-app-workspace__resize[data-app-workspace-resize="sidebar"]),
501
+ .cloud-app-canvas[data-shell-focus]
502
+ .k2b-app-workspace:not(.k2b-app-workspace *)
503
+ > .k2b-app-workspace__content
504
+ > .k2b-app-workspace__main
505
+ > :is(
506
+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary),
507
+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary) + .k2b-app-workspace__resize,
508
+ .k2b-app-workspace__resize:has(+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary))
509
+ ),
510
+ .cloud-app-canvas[data-shell-focus]
511
+ .k2b-app-workspace:not(.k2b-app-workspace *)
512
+ > .k2b-app-workspace__content
513
+ > :is(solid-island, solid-client)
514
+ > .k2b-app-workspace__main
515
+ > :is(
516
+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary),
517
+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary) + .k2b-app-workspace__resize,
518
+ .k2b-app-workspace__resize:has(+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary))
519
+ ) {
520
+ display: none;
392
521
  }
393
522
  }
394
523
 
@@ -750,10 +750,12 @@
750
750
  @apply text-[0.6875rem] font-medium normal-case tracking-normal mb-5 text-dimmed select-none;
751
751
  }
752
752
 
753
+ /* A rail control: a 44 px target in the 56 px rail. Its icon is 20 px
754
+ (`text-xl` on the icon). */
753
755
  @utility rail-item {
754
756
  border-right: 0;
755
757
  border-radius: var(--ui-radius-surface);
756
- @apply w-8 h-8;
758
+ @apply w-11 h-11;
757
759
  @apply flex items-center justify-center text-dimmed;
758
760
  @apply transition-[background-color,color] duration-150 ease-out;
759
761
 
@@ -795,6 +797,12 @@
795
797
  pointer-events: none;
796
798
  }
797
799
 
800
+ /* On the corner of the rail icon, not of its 44 px target. */
801
+ .rail-item > .cloud-app-badge {
802
+ top: 0.25rem;
803
+ right: 0.1875rem;
804
+ }
805
+
798
806
  .app-icon > .cloud-app-badge {
799
807
  top: -0.375rem;
800
808
  right: -0.375rem;
@@ -841,8 +849,8 @@
841
849
  .layout-profile-preferences__link,
842
850
  .layout-profile-preferences__dropdown-trigger {
843
851
  display: none;
844
- width: 2rem;
845
- height: 2rem;
852
+ width: 2.75rem;
853
+ height: 2.75rem;
846
854
  align-items: center;
847
855
  justify-content: center;
848
856
  padding: 0;