sh3-core 0.13.2 → 0.13.3

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.
Files changed (60) hide show
  1. package/dist/actions/MenuButton.svelte +2 -1
  2. package/dist/actions/contextMenuModel.d.ts +1 -1
  3. package/dist/actions/contextMenuModel.js +2 -1
  4. package/dist/actions/dispatcher.svelte.d.ts +1 -1
  5. package/dist/actions/dispatcher.svelte.js +2 -1
  6. package/dist/actions/listActive.d.ts +1 -1
  7. package/dist/actions/listActive.js +2 -1
  8. package/dist/actions/listeners.d.ts +1 -1
  9. package/dist/actions/listeners.js +6 -5
  10. package/dist/actions/menuBarModel.js +3 -2
  11. package/dist/actions/paletteModel.js +2 -1
  12. package/dist/actions/resolveLabel.test.d.ts +1 -0
  13. package/dist/actions/resolveLabel.test.js +14 -0
  14. package/dist/actions/types.d.ts +12 -1
  15. package/dist/actions/types.js +7 -1
  16. package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
  17. package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
  18. package/dist/app/store/InstalledView.svelte +8 -54
  19. package/dist/app/store/UninstallAppDialog.svelte +86 -0
  20. package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
  21. package/dist/app/store/permissionConfirm.d.ts +4 -0
  22. package/dist/app/store/permissionConfirm.js +28 -0
  23. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  24. package/dist/app/store/storeShard.svelte.js +42 -9
  25. package/dist/app/store/updatePackage.test.d.ts +1 -0
  26. package/dist/app/store/updatePackage.test.js +34 -0
  27. package/dist/app/store/verbs.d.ts +1 -0
  28. package/dist/app/store/verbs.js +79 -5
  29. package/dist/app/store/verbs.test.d.ts +1 -0
  30. package/dist/app/store/verbs.test.js +56 -0
  31. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  32. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  33. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  34. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  35. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  36. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  37. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  38. package/dist/app-appearance/appearanceState.test.js +30 -0
  39. package/dist/app-appearance/index.d.ts +3 -0
  40. package/dist/app-appearance/index.js +2 -0
  41. package/dist/app-appearance/types.d.ts +11 -0
  42. package/dist/app-appearance/types.js +1 -0
  43. package/dist/apps/types.d.ts +7 -0
  44. package/dist/assets/iconIds.generated.d.ts +2 -0
  45. package/dist/assets/iconIds.generated.js +154 -0
  46. package/dist/host.js +2 -1
  47. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  48. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  49. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  50. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  51. package/dist/projects-shard/ProjectManage.svelte +14 -4
  52. package/dist/sh3core-shard/ShellHome.svelte +64 -38
  53. package/dist/sh3core-shard/appActions.d.ts +13 -0
  54. package/dist/sh3core-shard/appActions.js +181 -0
  55. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  56. package/dist/sh3core-shard/appActions.test.js +25 -0
  57. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +2 -2
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { setAppearance, getAppearance, __resetForTests } from './appearanceState.svelte';
3
+ describe('appearanceShard get/set', () => {
4
+ beforeEach(() => __resetForTests());
5
+ it('returns undefined for an unset app', () => {
6
+ expect(getAppearance('foo')).toBeUndefined();
7
+ });
8
+ it('round-trips an icon override', () => {
9
+ setAppearance('foo', { icon: 'house' });
10
+ expect(getAppearance('foo')).toEqual({ icon: 'house' });
11
+ });
12
+ it('round-trips a color override', () => {
13
+ setAppearance('foo', { color: '#ff0000' });
14
+ expect(getAppearance('foo')).toEqual({ color: '#ff0000' });
15
+ });
16
+ it('round-trips a label override', () => {
17
+ setAppearance('foo', { label: 'My Label' });
18
+ expect(getAppearance('foo')).toEqual({ label: 'My Label' });
19
+ });
20
+ it('clears the entry when all fields are undefined', () => {
21
+ setAppearance('foo', { icon: 'house', color: '#ff0000', label: 'X' });
22
+ setAppearance('foo', { icon: undefined, color: undefined, label: undefined });
23
+ expect(getAppearance('foo')).toBeUndefined();
24
+ });
25
+ it('clears the entry when given undefined directly', () => {
26
+ setAppearance('foo', { icon: 'house' });
27
+ setAppearance('foo', undefined);
28
+ expect(getAppearance('foo')).toBeUndefined();
29
+ });
30
+ });
@@ -0,0 +1,3 @@
1
+ export { appearanceShard } from './appearanceShard.svelte';
2
+ export { getAppearance, setAppearance } from './appearanceState.svelte';
3
+ export type { AppAppearance } from './types';
@@ -0,0 +1,2 @@
1
+ export { appearanceShard } from './appearanceShard.svelte';
2
+ export { getAppearance, setAppearance } from './appearanceState.svelte';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Per-app per-user-per-browser visual override. Stored in the user zone
3
+ * of the __app-appearance__ shard. Every field is optional — an empty
4
+ * AppAppearance object is treated as "no override" and removed from the
5
+ * map by setAppearance().
6
+ */
7
+ export interface AppAppearance {
8
+ icon?: string;
9
+ color?: string;
10
+ label?: string;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -74,6 +74,13 @@ export interface AppManifest {
74
74
  * When absent, the canonical fallback is used. See MenuContainer.
75
75
  */
76
76
  menus?: MenuContainer[];
77
+ /**
78
+ * Optional default home-card icon — a lucide icon id from the bundled
79
+ * sprite (see `src/assets/icons.svg`). User-set per-browser overrides
80
+ * (via the home-card Customize action) take precedence; if neither is
81
+ * set the framework falls back to `box`.
82
+ */
83
+ icon?: string;
77
84
  }
78
85
  /**
79
86
  * Context object passed to `App.activate`. Provides app-scoped state zones
@@ -0,0 +1,2 @@
1
+ export declare const ICON_IDS: readonly ["activity", "align-horizontal-justify-center", "align-horizontal-justify-end", "align-horizontal-justify-start", "app-window", "archive", "archive-restore", "axis-3d", "box", "brick-wall", "bug", "building-2", "cable", "calendar", "camera", "check", "chevron-down", "chevron-right", "circle-check", "circle-dot", "circle-minus", "circle-x", "clipboard", "clipboard-paste", "clock", "compass", "component", "copy", "cpu", "crop", "crosshair", "crown", "dollar-sign", "download", "droplet", "eraser", "euro", "external-link", "eye", "eye-off", "file", "file-archive", "file-diff", "file-plus", "file-text", "flame", "flip-horizontal-2", "flip-vertical-2", "folder", "folder-open", "folder-plus", "folder-tree", "gallery-vertical-end", "gamepad-2", "gauge", "gem", "git-branch", "git-commit-horizontal", "git-merge", "globe", "grid-2x2", "grid-3x3", "group", "hard-drive", "heart", "history", "house", "image", "info", "joystick", "key", "layers", "layout-dashboard", "layout-grid", "layout-list", "layout-panel-left", "layout-panel-top", "layout-template", "lightbulb", "link", "list-ordered", "list-tree", "lock", "log-out", "magnet", "mail", "map", "maximize", "minimize", "moon", "mouse-pointer", "move", "move-3d", "music", "navigation", "network", "notebook-pen", "palette", "pause", "pencil", "pipette", "play", "plus", "pointer", "pound-sterling", "receipt", "redo-2", "refresh-cw", "rocket", "rotate-3d", "rotate-ccw", "rotate-cw", "ruler", "save", "scissors", "scroll-text", "search", "send", "server", "settings", "shield", "skull", "sliders-horizontal", "snowflake", "sparkles", "square", "square-terminal", "star", "sun", "sword", "table-properties", "target", "texture", "timer", "trash-2", "triangle-alert", "type", "undo-2", "ungroup", "unity", "upload", "user", "users", "video", "volume-2", "wand-sparkles", "wind", "x", "zap", "zoom-in", "zoom-out"];
2
+ export type IconId = (typeof ICON_IDS)[number];
@@ -0,0 +1,154 @@
1
+ // GENERATED — do not edit. See scripts/sync-icon-ids.ts
2
+ export const ICON_IDS = [
3
+ 'activity',
4
+ 'align-horizontal-justify-center',
5
+ 'align-horizontal-justify-end',
6
+ 'align-horizontal-justify-start',
7
+ 'app-window',
8
+ 'archive',
9
+ 'archive-restore',
10
+ 'axis-3d',
11
+ 'box',
12
+ 'brick-wall',
13
+ 'bug',
14
+ 'building-2',
15
+ 'cable',
16
+ 'calendar',
17
+ 'camera',
18
+ 'check',
19
+ 'chevron-down',
20
+ 'chevron-right',
21
+ 'circle-check',
22
+ 'circle-dot',
23
+ 'circle-minus',
24
+ 'circle-x',
25
+ 'clipboard',
26
+ 'clipboard-paste',
27
+ 'clock',
28
+ 'compass',
29
+ 'component',
30
+ 'copy',
31
+ 'cpu',
32
+ 'crop',
33
+ 'crosshair',
34
+ 'crown',
35
+ 'dollar-sign',
36
+ 'download',
37
+ 'droplet',
38
+ 'eraser',
39
+ 'euro',
40
+ 'external-link',
41
+ 'eye',
42
+ 'eye-off',
43
+ 'file',
44
+ 'file-archive',
45
+ 'file-diff',
46
+ 'file-plus',
47
+ 'file-text',
48
+ 'flame',
49
+ 'flip-horizontal-2',
50
+ 'flip-vertical-2',
51
+ 'folder',
52
+ 'folder-open',
53
+ 'folder-plus',
54
+ 'folder-tree',
55
+ 'gallery-vertical-end',
56
+ 'gamepad-2',
57
+ 'gauge',
58
+ 'gem',
59
+ 'git-branch',
60
+ 'git-commit-horizontal',
61
+ 'git-merge',
62
+ 'globe',
63
+ 'grid-2x2',
64
+ 'grid-3x3',
65
+ 'group',
66
+ 'hard-drive',
67
+ 'heart',
68
+ 'history',
69
+ 'house',
70
+ 'image',
71
+ 'info',
72
+ 'joystick',
73
+ 'key',
74
+ 'layers',
75
+ 'layout-dashboard',
76
+ 'layout-grid',
77
+ 'layout-list',
78
+ 'layout-panel-left',
79
+ 'layout-panel-top',
80
+ 'layout-template',
81
+ 'lightbulb',
82
+ 'link',
83
+ 'list-ordered',
84
+ 'list-tree',
85
+ 'lock',
86
+ 'log-out',
87
+ 'magnet',
88
+ 'mail',
89
+ 'map',
90
+ 'maximize',
91
+ 'minimize',
92
+ 'moon',
93
+ 'mouse-pointer',
94
+ 'move',
95
+ 'move-3d',
96
+ 'music',
97
+ 'navigation',
98
+ 'network',
99
+ 'notebook-pen',
100
+ 'palette',
101
+ 'pause',
102
+ 'pencil',
103
+ 'pipette',
104
+ 'play',
105
+ 'plus',
106
+ 'pointer',
107
+ 'pound-sterling',
108
+ 'receipt',
109
+ 'redo-2',
110
+ 'refresh-cw',
111
+ 'rocket',
112
+ 'rotate-3d',
113
+ 'rotate-ccw',
114
+ 'rotate-cw',
115
+ 'ruler',
116
+ 'save',
117
+ 'scissors',
118
+ 'scroll-text',
119
+ 'search',
120
+ 'send',
121
+ 'server',
122
+ 'settings',
123
+ 'shield',
124
+ 'skull',
125
+ 'sliders-horizontal',
126
+ 'snowflake',
127
+ 'sparkles',
128
+ 'square',
129
+ 'square-terminal',
130
+ 'star',
131
+ 'sun',
132
+ 'sword',
133
+ 'table-properties',
134
+ 'target',
135
+ 'texture',
136
+ 'timer',
137
+ 'trash-2',
138
+ 'triangle-alert',
139
+ 'type',
140
+ 'undo-2',
141
+ 'ungroup',
142
+ 'unity',
143
+ 'upload',
144
+ 'user',
145
+ 'users',
146
+ 'video',
147
+ 'volume-2',
148
+ 'wand-sparkles',
149
+ 'wind',
150
+ 'x',
151
+ 'zap',
152
+ 'zoom-in',
153
+ 'zoom-out',
154
+ ];
package/dist/host.js CHANGED
@@ -23,6 +23,7 @@ import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
23
23
  import { shellShard } from './shell-shard/shellShard.svelte';
24
24
  import { storeShard } from './app/store/storeShard.svelte';
25
25
  import { projectsShard } from './projects-shard/projectsShard.svelte';
26
+ import { appearanceShard } from './app-appearance';
26
27
  import { __setBackend, backends } from './state/zones.svelte';
27
28
  import { loadInstalledPackages } from './registry/installer';
28
29
  import { setLocalOwner } from './auth/index';
@@ -67,7 +68,7 @@ export async function bootstrap(config) {
67
68
  }
68
69
  const exShards = new Set(config === null || config === void 0 ? void 0 : config.excludeShards);
69
70
  // 1. Framework-owned shards
70
- const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard];
71
+ const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard, appearanceShard];
71
72
  for (const shard of frameworkShards) {
72
73
  if (!exShards.has(shard.manifest.id)) {
73
74
  registerShardInternal(shard);
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ /*
3
+ * IconPicker — searchable grid of icon ids from the bundled lucide
4
+ * sprite. The "(none)" tile clears the value. Composes the widget
5
+ * commit-only event contract (CommitOnlyEvents<string | undefined>).
6
+ */
7
+
8
+ import type { CommitOnlyEvents } from './_contract';
9
+ import { ICON_IDS } from '../../assets/iconIds.generated';
10
+ import iconsUrl from '../../assets/icons.svg';
11
+
12
+ type Props = {
13
+ value?: string | undefined;
14
+ label?: string;
15
+ disabled?: boolean;
16
+ } & CommitOnlyEvents<string | undefined>;
17
+
18
+ let {
19
+ value = $bindable(undefined),
20
+ label,
21
+ disabled = false,
22
+ onchange,
23
+ }: Props = $props();
24
+
25
+ let query = $state('');
26
+
27
+ const filtered = $derived(
28
+ query.trim() === ''
29
+ ? ICON_IDS
30
+ : ICON_IDS.filter((id) => id.includes(query.trim().toLowerCase())),
31
+ );
32
+
33
+ function pick(id: string | undefined): void {
34
+ if (disabled) return;
35
+ value = id;
36
+ onchange?.(id);
37
+ }
38
+ </script>
39
+
40
+ <div class="sh3-icon-picker" class:sh3-icon-picker--disabled={disabled}>
41
+ {#if label}<span class="sh3-icon-picker__label">{label}</span>{/if}
42
+ <input
43
+ type="search"
44
+ placeholder="Search icons…"
45
+ bind:value={query}
46
+ {disabled}
47
+ aria-label="Filter icons"
48
+ />
49
+ <div class="sh3-icon-picker__grid">
50
+ <button
51
+ type="button"
52
+ class="sh3-icon-picker__tile sh3-icon-picker__tile--none"
53
+ class:sh3-icon-picker__tile--selected={value === undefined}
54
+ aria-label="No icon"
55
+ {disabled}
56
+ onclick={() => pick(undefined)}
57
+ >×</button>
58
+ {#each filtered as id (id)}
59
+ <button
60
+ type="button"
61
+ class="sh3-icon-picker__tile"
62
+ class:sh3-icon-picker__tile--selected={value === id}
63
+ aria-label={id}
64
+ title={id}
65
+ {disabled}
66
+ onclick={() => pick(id)}
67
+ >
68
+ <svg viewBox="0 0 24 24"><use href="{iconsUrl}#{id}" /></svg>
69
+ </button>
70
+ {/each}
71
+ </div>
72
+ </div>
73
+
74
+ <style>
75
+ .sh3-icon-picker { display: flex; flex-direction: column; gap: 6px; }
76
+ .sh3-icon-picker__label { font-size: 13px; color: var(--shell-fg-muted); }
77
+ .sh3-icon-picker input[type="search"] {
78
+ background: var(--shell-bg-elevated);
79
+ color: var(--shell-fg);
80
+ border: 1px solid var(--shell-border);
81
+ border-radius: var(--shell-radius-sm, 3px);
82
+ padding: 6px 8px; font: inherit; font-size: 13px;
83
+ }
84
+ .sh3-icon-picker__grid {
85
+ display: grid;
86
+ grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
87
+ gap: 4px;
88
+ max-height: 220px;
89
+ overflow-y: auto;
90
+ padding: 4px;
91
+ background: var(--shell-bg);
92
+ border: 1px solid var(--shell-border);
93
+ border-radius: var(--shell-radius-sm, 3px);
94
+ }
95
+ .sh3-icon-picker__tile {
96
+ aspect-ratio: 1 / 1;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ background: var(--shell-bg-elevated);
101
+ color: var(--shell-fg);
102
+ border: 1px solid transparent;
103
+ border-radius: var(--shell-radius-sm, 3px);
104
+ cursor: pointer;
105
+ padding: 0; font: inherit;
106
+ }
107
+ .sh3-icon-picker__tile--none { font-size: 16px; color: var(--shell-fg-muted); }
108
+ .sh3-icon-picker__tile:hover { border-color: var(--shell-accent); }
109
+ .sh3-icon-picker__tile--selected {
110
+ outline: 2px solid var(--shell-accent);
111
+ outline-offset: -2px;
112
+ }
113
+ .sh3-icon-picker__tile svg { width: 18px; height: 18px; }
114
+ .sh3-icon-picker--disabled .sh3-icon-picker__tile { cursor: not-allowed; opacity: 0.5; }
115
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ type Props = {
3
+ value?: string | undefined;
4
+ label?: string;
5
+ disabled?: boolean;
6
+ } & CommitOnlyEvents<string | undefined>;
7
+ declare const IconPicker: import("svelte").Component<Props, {}, "value">;
8
+ type IconPicker = ReturnType<typeof IconPicker>;
9
+ export default IconPicker;
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import IconPicker from './IconPicker.svelte';
4
+ describe('IconPicker', () => {
5
+ it('renders the (none) tile and at least one icon tile', () => {
6
+ const { container } = render(IconPicker, { props: {} });
7
+ const tiles = container.querySelectorAll('.sh3-icon-picker__tile');
8
+ expect(tiles.length).toBeGreaterThan(1);
9
+ expect(tiles[0].getAttribute('aria-label')).toBe('No icon');
10
+ });
11
+ it('filters tiles by search input', async () => {
12
+ const { container } = render(IconPicker, { props: {} });
13
+ const search = container.querySelector('input[type="search"]');
14
+ await fireEvent.input(search, { target: { value: 'house' } });
15
+ const visible = container.querySelectorAll('.sh3-icon-picker__tile:not([hidden])');
16
+ expect(visible.length).toBeGreaterThan(0);
17
+ expect(visible.length).toBeLessThan(20);
18
+ });
19
+ it('calls onchange with the clicked icon id', async () => {
20
+ let lastValue = '__sentinel__';
21
+ const { container } = render(IconPicker, {
22
+ props: {
23
+ onchange: (v) => { lastValue = v; },
24
+ },
25
+ });
26
+ const tiles = Array.from(container.querySelectorAll('.sh3-icon-picker__tile'));
27
+ await fireEvent.click(tiles[1]);
28
+ expect(lastValue).toEqual(expect.any(String));
29
+ expect(lastValue).not.toBe('__sentinel__');
30
+ });
31
+ it('calls onchange with undefined when (none) tile is clicked', async () => {
32
+ let lastValue = 'house';
33
+ const { container } = render(IconPicker, {
34
+ props: {
35
+ value: 'house',
36
+ onchange: (v) => { lastValue = v; },
37
+ },
38
+ });
39
+ const noneTile = container.querySelector('.sh3-icon-picker__tile');
40
+ await fireEvent.click(noneTile);
41
+ expect(lastValue).toBeUndefined();
42
+ });
43
+ });
@@ -8,6 +8,7 @@
8
8
  * "include myself" works without looking up the underlying user id.
9
9
  */
10
10
 
11
+ import { untrack } from 'svelte';
11
12
  import { projectsApi, type ProjectRecord } from './projectsApi';
12
13
  import { refreshProjects } from './projectsShard.svelte';
13
14
  import { getUser } from '../auth/auth.svelte';
@@ -26,12 +27,21 @@
26
27
  const isEdit = $derived(project !== null);
27
28
  const me = $derived(getUser());
28
29
 
29
- let name = $state(project?.name ?? '');
30
- let description = $state(project?.description ?? '');
30
+ // Form fields snapshot the project on mount; the modal is recreated per
31
+ // open, so prop changes after mount aren't expected. untrack defers the
32
+ // reactive read so Svelte 5 doesn't emit state_referenced_locally.
33
+ let name = $state(untrack(() => project?.name ?? ''));
34
+ let description = $state(untrack(() => project?.description ?? ''));
31
35
  let members = $state<string[]>(
32
- project ? [...project.members] : (me?.id ? [me.id] : []),
36
+ untrack(() => {
37
+ if (project) return [...project.members];
38
+ const userId = getUser()?.id;
39
+ return userId ? [userId] : [];
40
+ }),
41
+ );
42
+ let appAllowlist = $state<string[]>(
43
+ untrack(() => (project ? [...project.appAllowlist] : [])),
33
44
  );
34
- let appAllowlist = $state<string[]>(project ? [...project.appAllowlist] : []);
35
45
  let saving = $state(false);
36
46
  let error = $state<string | null>(null);
37
47
 
@@ -12,6 +12,22 @@
12
12
  import ProjectsSection from '../projects-shard/ProjectsSection.svelte';
13
13
  import { sessionState } from '../projects/session-state.svelte';
14
14
  import { projectsState } from '../projects-shard/projectsShard.svelte';
15
+ import { shell } from '../shellRuntime.svelte';
16
+ import { makeSelectionApi } from '../actions/selection.svelte';
17
+ import { getAppearance } from '../app-appearance';
18
+ import iconsUrl from '../assets/icons.svg';
19
+
20
+ const homeSelection = makeSelectionApi('__sh3core__');
21
+
22
+ function openAppContextMenu(event: MouseEvent, appId: string): void {
23
+ event.preventDefault();
24
+ homeSelection.set({ type: 'app', ref: { appId } });
25
+ shell.actions.openContextMenu({
26
+ x: event.clientX,
27
+ y: event.clientY,
28
+ scope: { element: 'app' },
29
+ });
30
+ }
15
31
 
16
32
  let filter = $state('');
17
33
 
@@ -71,17 +87,20 @@
71
87
  <h2 class="shell-home-section-title">Apps</h2>
72
88
  <div class="shell-home-grid">
73
89
  {#each userApps as manifest (manifest.id)}
90
+ {@const appearance = getAppearance(manifest.id)}
74
91
  <button
75
92
  type="button"
76
93
  class="shell-home-card"
94
+ class:shell-home-card--tinted={appearance?.color}
95
+ style:--card-color={appearance?.color ?? 'transparent'}
96
+ data-sh3-scope="element:app"
77
97
  onclick={() => launchApp(manifest.id)}
78
- title="Launch {manifest.label}"
98
+ oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
79
99
  >
80
- <div class="shell-home-card-label">{manifest.label}</div>
81
- <div class="shell-home-card-meta">
82
- <span class="shell-home-card-id">{manifest.id}</span>
83
- <span class="shell-home-card-version">v{manifest.version}</span>
84
- </div>
100
+ <span class="shell-home-card-square">
101
+ <svg class="shell-home-card-icon"><use href="{iconsUrl}#{appearance?.icon ?? manifest.icon ?? 'box'}" /></svg>
102
+ </span>
103
+ <span class="shell-home-card-label">{appearance?.label ?? manifest.label}</span>
85
104
  </button>
86
105
  {/each}
87
106
  </div>
@@ -93,17 +112,20 @@
93
112
  <h2 class="shell-home-section-title">Admin</h2>
94
113
  <div class="shell-home-grid">
95
114
  {#each adminApps as manifest (manifest.id)}
115
+ {@const appearance = getAppearance(manifest.id)}
96
116
  <button
97
117
  type="button"
98
118
  class="shell-home-card"
119
+ class:shell-home-card--tinted={appearance?.color}
120
+ style:--card-color={appearance?.color ?? 'transparent'}
121
+ data-sh3-scope="element:app"
99
122
  onclick={() => launchApp(manifest.id)}
100
- title="Launch {manifest.label}"
123
+ oncontextmenu={(e) => openAppContextMenu(e, manifest.id)}
101
124
  >
102
- <div class="shell-home-card-label">{manifest.label}</div>
103
- <div class="shell-home-card-meta">
104
- <span class="shell-home-card-id">{manifest.id}</span>
105
- <span class="shell-home-card-version">v{manifest.version}</span>
106
- </div>
125
+ <span class="shell-home-card-square">
126
+ <svg class="shell-home-card-icon"><use href="{iconsUrl}#{appearance?.icon ?? manifest.icon ?? 'box'}" /></svg>
127
+ </span>
128
+ <span class="shell-home-card-label">{appearance?.label ?? manifest.label}</span>
107
129
  </button>
108
130
  {/each}
109
131
  </div>
@@ -223,26 +245,34 @@
223
245
  }
224
246
  .shell-home-grid {
225
247
  display: grid;
226
- grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
227
- gap: 10px;
248
+ grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
249
+ gap: 18px 14px;
228
250
  }
229
251
  .shell-home-card {
230
- aspect-ratio: 1 / 1;
231
252
  display: flex;
232
253
  flex-direction: column;
233
- justify-content: space-between;
234
- text-align: left;
235
- padding: 10px;
236
- background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
237
- border: 1px solid var(--shell-border);
238
- border-radius: var(--shell-radius-md);
254
+ align-items: center;
255
+ gap: 6px;
256
+ padding: 0;
257
+ background: transparent;
258
+ border: none;
239
259
  color: inherit;
240
260
  font: inherit;
241
261
  cursor: pointer;
262
+ }
263
+ .shell-home-card-square {
264
+ width: 64px;
265
+ height: 64px;
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
270
+ border: 1px solid var(--shell-border);
271
+ border-radius: var(--shell-radius-md);
242
272
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
243
273
  transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
244
274
  }
245
- .shell-home-card:hover {
275
+ .shell-home-card:hover .shell-home-card-square {
246
276
  border-color: var(--shell-accent);
247
277
  transform: translateY(-1px);
248
278
  box-shadow:
@@ -252,36 +282,32 @@
252
282
  }
253
283
  .shell-home-card:focus-visible {
254
284
  outline: none;
285
+ }
286
+ .shell-home-card:focus-visible .shell-home-card-square {
255
287
  border-color: var(--shell-accent);
256
288
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 40%, transparent);
257
289
  }
258
- .shell-home-card:active {
290
+ .shell-home-card:active .shell-home-card-square {
259
291
  transform: translateY(0);
260
292
  }
261
293
  .shell-home-card-label {
262
294
  font-weight: 600;
263
- font-size: 12px;
295
+ font-size: 11px;
264
296
  line-height: 1.2;
297
+ text-align: center;
265
298
  overflow: hidden;
266
299
  display: -webkit-box;
267
300
  -webkit-box-orient: vertical;
268
301
  -webkit-line-clamp: 2;
269
302
  line-clamp: 2;
303
+ word-break: break-word;
270
304
  }
271
- .shell-home-card-meta {
272
- display: flex;
273
- flex-direction: column;
274
- gap: 1px;
275
- font-size: 9px;
276
- color: var(--shell-fg-subtle);
277
- min-width: 0;
278
- }
279
- .shell-home-card-id {
280
- overflow: hidden;
281
- text-overflow: ellipsis;
282
- white-space: nowrap;
305
+ .shell-home-card-icon {
306
+ width: 28px;
307
+ height: 28px;
308
+ color: var(--shell-fg);
283
309
  }
284
- .shell-home-card-version {
285
- color: var(--shell-fg-muted);
310
+ .shell-home-card--tinted .shell-home-card-square {
311
+ background: var(--card-color);
286
312
  }
287
313
  </style>
@@ -0,0 +1,13 @@
1
+ import type { ShardContext } from '../shards/types';
2
+ export interface AppActionGate {
3
+ admin: boolean;
4
+ builtin: boolean;
5
+ }
6
+ export declare function computeAppActionDisabled(g: AppActionGate): boolean;
7
+ export declare function computeAppActionLabelSuffix(g: AppActionGate): string;
8
+ /**
9
+ * Register the three element-scope app actions on the given shard context.
10
+ * Returns a disposer that unregisters all three (the framework expects the
11
+ * shard's activate to keep the disposers alive across deactivate).
12
+ */
13
+ export declare function registerAppActions(ctx: ShardContext): () => void;