@tendstead/ui 0.0.0-stage → 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tendstead
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @tendstead/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Svelte 5 / TypeScript shell for independent Tendstead tools. This is a working package and synthetic demo, with no backend, authentication, or live downloads. Licensed under the [MIT License](LICENSE).
4
+
5
+ ```sh
6
+ npm ci
7
+ npm run dev # http://localhost:5173; watches package + demo
8
+ npm run build # dist package + demo-dist static demo
9
+ npm run preview # built demo, port 4173 by default
10
+ ```
11
+
12
+ Development and CI use Node 26.13.0 (Active LTS) and require Svelte >=5.20. The demo consumes the package's built exports, as an independent app would. Source changes rebuild `dist` in dev mode. Demo links update history; Back/Forward work. Try `/?app=downloader` or `/?theme=dark&long=1&services=1&account=1`. `route=/receipts/review/42`, `controls=1`, `admin=1`, and `denied=1` provide deterministic fixtures. Add `accountForm=1&account=1` to exercise consumer account labels and explicit input IDs. Expand Demo configuration for reactive controls. Receipt Menu includes Collections and Statistics under Explore, a zero-badge Review queue, and optional Administration (`admin=1` or its fixture control). Connected-service examples are synthetic Shopping list (receipts) and Media library (downloader) links supplied by the demo, with no package-owned catalog. All sample data and actions stay local.
13
+
14
+ ## Use in an app
15
+
16
+ Build and `npm pack` locally, then install the resulting tarball in your app. Import the theme explicitly; no Tailwind, reset or SvelteKit required.
17
+
18
+ Release-labelled PRs automatically publish versioned tarballs on GitHub after
19
+ verification and publish the same package to npm through trusted publishing.
20
+ See [the release guide](docs/releases.md) for labels, installation, and retry instructions.
21
+
22
+ ```svelte
23
+ <script lang="ts">
24
+ import { AppShell, type Destination } from '@tendstead/ui';
25
+ import '@tendstead/ui/theme.css';
26
+ let pathname = $state('/'); // supply this from your app's router
27
+ const destinations: Destination[] = [
28
+ { id: 'overview', label: 'Overview', href: '/', match: 'exact' },
29
+ { id: 'receipts', label: 'Receipts', href: '/receipts', badge: 0 },
30
+ { id: 'settings', label: 'Settings', href: '/settings' },
31
+ ];
32
+ </script>
33
+ <AppShell brand="Tendstead" appLabel="Receipts" pageTitle="Overview"
34
+ {pathname} {destinations} mobilePrimaryIds={['overview', 'receipts']}
35
+ primaryAction={{ id: 'add', label: 'Add', onclick: () => {} }}>
36
+ {#snippet pageActions()}<button type="button">Export</button>{/snippet}
37
+ <p>Your app content.</p>
38
+ </AppShell>
39
+ ```
40
+
41
+ The package exports `AppShell` and `AppShellProps`, `Destination`, `NavigationGroup`, `ServiceLink`, `PrimaryAction`, `ShellIcon` types. Destinations have unique IDs and optional app-supplied Svelte icon components, `groupId`, `badge` (including zero), `visible`, `permitted` and `match: 'exact' | 'prefix'`. Supply `groups={[{id:'manage',label:'Manage'}]}` to label groups. Active matching expects root-relative destination paths such as `/receipts`; the most specific segment-aware route wins, and query/hash are ignored. Relative hrefs are allowed for navigation but are not resolved against the current route for active matching. Filtered or unknown mobile IDs disappear; every permitted destination remains in Menu. Keep primary IDs to a small curated set. Primary actions support `href` or `onclick`; account and pageActions are optional Svelte snippets.
42
+
43
+ Links use normal browser navigation. For client routing, supply `onNavigate={(event, destination) => { event.preventDefault(); router.goto(destination.href); }}` and feed the current pathname back to the shell. The callback runs for ordinary left clicks only; modified clicks retain browser behavior. The shell does not intercept unrelated content links. Apps own route transitions, data, authorization and document titles.
44
+
45
+ Optional connected services use ordinary same-tab anchors:
46
+
47
+ ```ts
48
+ const serviceLinks = [
49
+ { id: 'shopping', label: 'Shopping list', href: 'https://shopping.example.test/' },
50
+ // Only include an admin service if this app's trusted configuration permits it.
51
+ ];
52
+ ```
53
+
54
+ Pass `serviceLinks` to the shell. Empty is the default, with no placeholder section. Services appear in the sidebar and complete mobile menu, never primary tabs. The shell does no discovery, probing, credential sharing or server communication. Visible/permitted flags only control presentation: each destination server must authorize access. Unsafe obvious non-navigation protocols are omitted. App configuration and contextual handoff/return paths remain app responsibilities.
55
+
56
+ ## Theme and layout
57
+
58
+ Set `data-theme="dark"` or `data-theme="light"` on an ancestor. Override `--ts-primary`, `--ts-on-primary`, `--ts-nav`, `--ts-on-nav`, `--ts-selection`, `--ts-on-selection`, `--ts-surface`, `--ts-surface-subtle`, `--ts-text`, `--ts-muted`, `--ts-border`, `--ts-focus`, and `--ts-font` there. Defaults use neutral surfaces and purple `#6750A4`; token changes need contrast review. Theme CSS applies shell geometry and box sizing inside the shell; ordinary content links retain app/browser styling. No external fonts or images are fetched.
59
+
60
+ Desktop sidebar starts at 768 CSS px. It stays within the viewport while the document scrolls, and its own scroll area is keyboard focusable for short screens and long destination maps. Smaller layouts use primary navigation and a complete modal drawer with focus trapping/return, Escape/backdrop dismissal, inert background, scroll lock, and resize/unmount cleanup. Bottom clearance follows the measured navigation height; safe areas are included. Your content must still wrap responsibly.
61
+
62
+ ## Docker
63
+
64
+ ```sh
65
+ docker compose up --build dev # http://localhost:5173; source + demo watched
66
+ docker compose up --build demo # http://localhost:8080; static demo, SPA fallback
67
+ docker compose run --build --rm test
68
+ ```
69
+
70
+ `Dockerfile` provides dev, build and static demo targets; `Dockerfile.test` uses `mcr.microsoft.com/playwright:v1.63.0-noble`, matching pinned Playwright 1.63.0. No image is pushed or deployed. For canonical visual changes and ownership-preserving mounted test runs, see [CONTRIBUTING.md](CONTRIBUTING.md).
71
+
72
+ ## Verify
73
+
74
+ ```sh
75
+ npm run check
76
+ npm run test:unit # navigation/config contracts
77
+ npm run test:consumer # npm pack -> independent temp app build + SSR
78
+ npm run test:browser # Chromium, Firefox, WebKit
79
+ npm run test:visual # Chromium reference comparisons; never updates
80
+ npm run verify # all above, plus package/demo build
81
+ ```
82
+
83
+ Install browser binaries with `npx playwright install --with-deps` for host behavior runs. Use the canonical container for screenshot comparisons. CI runs the same full verification and retains HTML reports, traces, screenshot actual/diff files, and references. Initial automated baselines are provisional pending human design review. Automated accessibility and browser checks do not replace real phone, safe-area, touch or software-keyboard testing. See [CONTRIBUTING.md](CONTRIBUTING.md) for manual acceptance and baseline review.
84
+
85
+ Configuration may change while a drawer is open. When an app removes a focused navigation destination or account snippet, focus recovers inside the dialog; the standard modal close still returns focus to Menu.
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { createDialog } from '@melt-ui/svelte';
4
+ import type { AppShellProps, Destination } from './types';
5
+ import { activeDestination, availableDestinations, mobileDestinations, safeHref, shouldHandleNavigation } from './navigation';
6
+ import NavigationList from './internal/NavigationList.svelte';
7
+ import DestinationLink from './internal/DestinationLink.svelte';
8
+ import Action from './internal/Action.svelte';
9
+ let { brand, appLabel, pageTitle, pathname, destinations, groups=[], mobilePrimaryIds=[], primaryAction, serviceLinks=[], onNavigate, children, pageActions, account }:AppShellProps=$props();
10
+ const uid=$props.id();
11
+ const {elements:{trigger,overlay,content,title,description,close},states:{open}}=createDialog({portal:null});
12
+ let available=$derived(availableDestinations(destinations));
13
+ let active=$derived(activeDestination(destinations,pathname));
14
+ let mobile=$derived(mobileDestinations(destinations,mobilePrimaryIds));
15
+ let services=$derived(serviceLinks.filter(s=>safeHref(s.href)));
16
+ let mobileBar:HTMLElement;
17
+ let barHeight=$state(100);
18
+ function navigate(event:MouseEvent,destination:Destination) {
19
+ if (!shouldHandleNavigation(event)) return;
20
+ onNavigate?.(event,destination);
21
+ open.set(false);
22
+ }
23
+ // Recover focus when reactive app configuration removes the focused snippet.
24
+ // Melt remains responsible for trapping and returning focus during normal use.
25
+ function recoverDetachedFocus(node:HTMLElement) {
26
+ let focused:Element|null=node.contains(document.activeElement)?document.activeElement:null;
27
+ const remember=(event:FocusEvent)=>{ focused=event.target as Element; };
28
+ node.addEventListener('focusin',remember);
29
+ const observer=new MutationObserver(()=>{
30
+ if(focused && !focused.isConnected && !node.contains(document.activeElement)) {
31
+ node.focus({preventScroll:true});
32
+ }
33
+ });
34
+ observer.observe(node,{childList:true,subtree:true});
35
+ return {destroy(){ observer.disconnect();node.removeEventListener('focusin',remember); }};
36
+ }
37
+ onMount(()=>{
38
+ const desktop=window.matchMedia('(min-width: 768px)');
39
+ const resized=()=>{ if(desktop.matches) open.set(false); };
40
+ desktop.addEventListener('change',resized);
41
+ const observer=new ResizeObserver(entries=>{ barHeight=entries[0].borderBoxSize[0]?.blockSize ?? mobileBar.offsetHeight; });
42
+ observer.observe(mobileBar);
43
+ return ()=>{ desktop.removeEventListener('change',resized); observer.disconnect(); };
44
+ });
45
+ onDestroy(()=>open.set(false));
46
+ </script>
47
+
48
+ {#snippet brandBlock()}
49
+ <div class="ts-brand"><span class="ts-brand-mark" aria-hidden="true">t</span><div><strong>{brand}</strong><span class="ts-app-label">{appLabel}</span></div></div>
50
+ {/snippet}
51
+ {#snippet servicesBlock()}
52
+ {#if services.length}
53
+ <nav class="ts-services" aria-label="Connected services">
54
+ <h2 class="ts-section-label">Connected services</h2>
55
+ {#each services as service (service.id)}
56
+ <a class="ts-nav-link" href={service.href}>
57
+ {#if service.icon}<service.icon size={21} aria-hidden="true"/>{/if}
58
+ <span class="ts-nav-label">{service.label}</span><span aria-hidden="true">↗</span>
59
+ </a>
60
+ {/each}
61
+ </nav>
62
+ {/if}
63
+ {/snippet}
64
+
65
+ <div class="ts-shell" style:--ts-mobile-space={`${barHeight+24}px`}>
66
+ <div class="ts-shell-background" data-shell-background inert={$open}>
67
+ <a class="ts-skip" href={`#${uid}-content`}>Skip to content</a>
68
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex (The named overflow region must support native keyboard scrolling.) -->
69
+ <aside class="ts-sidebar" aria-label="Application sidebar" tabindex="0">
70
+ {@render brandBlock()}
71
+ <nav class="ts-main-nav" aria-label="Main navigation"><NavigationList destinations={available} {groups} activeId={active?.id} onclick={navigate}/></nav>
72
+ {@render servicesBlock()}
73
+ {#if account && !$open}<div class="ts-account">{@render account()}</div>{/if}
74
+ <p class="ts-sidebar-note">A little more in order.</p>
75
+ </aside>
76
+ <div class="ts-workspace">
77
+ <header class="ts-header">
78
+ <div class="ts-page-heading"><p class="ts-eyebrow">{appLabel}</p><h1>{pageTitle}</h1></div>
79
+ <div class="ts-header-actions">{#if pageActions}{@render pageActions()}{/if}{#if primaryAction}<div class="ts-desktop-action"><Action action={primaryAction}/></div>{/if}</div>
80
+ </header>
81
+ <main id={`${uid}-content`} class="ts-page-content" tabindex="-1">{@render children()}</main>
82
+ </div>
83
+ <nav class="ts-mobile-bar" bind:this={mobileBar} aria-label="Mobile navigation">
84
+ {#each mobile as destination,index (destination.id)}
85
+ {#if primaryAction && index===Math.ceil(mobile.length/2)}<Action action={primaryAction} mobile/>{/if}
86
+ <DestinationLink {destination} active={active?.id===destination.id} onclick={navigate}/>
87
+ {/each}
88
+ {#if primaryAction && mobile.length<2}<Action action={primaryAction} mobile/>{/if}
89
+ <button class="ts-menu-trigger" {...$trigger} use:trigger>
90
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span>Menu</span>
91
+ </button>
92
+ </nav>
93
+ </div>
94
+ {#if $open}
95
+ <div class="ts-overlay" data-drawer-overlay {...$overlay} use:overlay></div>
96
+ <div class="ts-drawer" {...$content} use:content use:recoverDetachedFocus>
97
+ <div class="ts-drawer-heading"><h2 {...$title} use:title>All destinations</h2><button class="ts-icon-button" aria-label="Close menu" {...$close} use:close>✕</button></div>
98
+ <p class="ts-drawer-description" {...$description} use:description>{brand} · {appLabel}</p>
99
+ <nav class="ts-drawer-nav" aria-label="All navigation"><NavigationList destinations={available} {groups} activeId={active?.id} onclick={navigate}/></nav>
100
+ {@render servicesBlock()}
101
+ {#if account}<div class="ts-account">{@render account()}</div>{/if}
102
+ </div>
103
+ {/if}
104
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { AppShellProps } from './types';
2
+ declare const AppShell: import("svelte").Component<AppShellProps, {}, "">;
3
+ type AppShell = ReturnType<typeof AppShell>;
4
+ export default AppShell;
@@ -0,0 +1,2 @@
1
+ export { default as AppShell } from './AppShell.svelte';
2
+ export type { AppShellProps, Destination, NavigationGroup, ServiceLink, PrimaryAction, ShellIcon } from './types';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as AppShell } from './AppShell.svelte';
@@ -0,0 +1,14 @@
1
+ <script lang="ts">
2
+ import type { PrimaryAction } from '../types';
3
+ import { safeHref } from '../navigation';
4
+ let { action, mobile=false }: { action:PrimaryAction; mobile?:boolean } = $props();
5
+ </script>
6
+ {#snippet label()}
7
+ {#if action.icon}<action.icon size={21} aria-hidden="true"/>{/if}
8
+ <span>{action.label}</span>
9
+ {/snippet}
10
+ {#if action.href && safeHref(action.href)}
11
+ <a class:ts-mobile-action={mobile} class="ts-action" href={action.href}>{@render label()}</a>
12
+ {:else if !action.href}
13
+ <button class:ts-mobile-action={mobile} class="ts-action" type="button" onclick={action.onclick}>{@render label()}</button>
14
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { PrimaryAction } from '../types';
2
+ type $$ComponentProps = {
3
+ action: PrimaryAction;
4
+ mobile?: boolean;
5
+ };
6
+ declare const Action: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Action = ReturnType<typeof Action>;
8
+ export default Action;
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import type { Destination } from '../types';
3
+ let { destination, active = false, onclick }: { destination: Destination; active?: boolean; onclick?: (event: MouseEvent, destination: Destination) => void } = $props();
4
+ </script>
5
+ <a class="ts-nav-link" href={destination.href} aria-current={active ? 'page' : undefined} onclick={(event) => onclick?.(event, destination)}>
6
+ {#if destination.icon}<destination.icon size={21} aria-hidden="true" />{/if}
7
+ <span class="ts-nav-label">{destination.label}</span>
8
+ {#if destination.badge !== undefined}<span class="ts-badge">{destination.badge}</span>{/if}
9
+ </a>
@@ -0,0 +1,9 @@
1
+ import type { Destination } from '../types';
2
+ type $$ComponentProps = {
3
+ destination: Destination;
4
+ active?: boolean;
5
+ onclick?: (event: MouseEvent, destination: Destination) => void;
6
+ };
7
+ declare const DestinationLink: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type DestinationLink = ReturnType<typeof DestinationLink>;
9
+ export default DestinationLink;
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ import type { Destination, NavigationGroup } from '../types';
3
+ import DestinationLink from './DestinationLink.svelte';
4
+ let { destinations, groups, activeId, onclick }: { destinations: Destination[]; groups: NavigationGroup[]; activeId?: string; onclick: (event:MouseEvent, destination:Destination)=>void } = $props();
5
+ let ungrouped = $derived(destinations.filter(d=>!d.groupId || !groups.some(g=>g.id===d.groupId)));
6
+ </script>
7
+ {#each ungrouped as destination (destination.id)}<DestinationLink {destination} active={activeId===destination.id} {onclick}/>{/each}
8
+ {#each groups as group (group.id)}
9
+ {@const items=destinations.filter(d=>d.groupId===group.id)}
10
+ {#if items.length}
11
+ <section class="ts-nav-group" aria-label={group.label}>
12
+ <h2 class="ts-section-label">{group.label}</h2>
13
+ {#each items as destination (destination.id)}<DestinationLink {destination} active={activeId===destination.id} {onclick}/>{/each}
14
+ </section>
15
+ {/if}
16
+ {/each}
@@ -0,0 +1,10 @@
1
+ import type { Destination, NavigationGroup } from '../types';
2
+ type $$ComponentProps = {
3
+ destinations: Destination[];
4
+ groups: NavigationGroup[];
5
+ activeId?: string;
6
+ onclick: (event: MouseEvent, destination: Destination) => void;
7
+ };
8
+ declare const NavigationList: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type NavigationList = ReturnType<typeof NavigationList>;
10
+ export default NavigationList;
@@ -0,0 +1,7 @@
1
+ import type { Destination } from './types';
2
+ /** Configuration comes from the app. Filtering is presentation, never authorization. */
3
+ export declare function safeHref(value: string): boolean;
4
+ export declare function availableDestinations(destinations: Destination[]): Destination[];
5
+ export declare function activeDestination(destinations: Destination[], pathname: string): Destination | undefined;
6
+ export declare function mobileDestinations(destinations: Destination[], ids: string[]): Destination[];
7
+ export declare function shouldHandleNavigation(event: Pick<MouseEvent, 'button' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey' | 'defaultPrevented'>): boolean;
@@ -0,0 +1,30 @@
1
+ /** Configuration comes from the app. Filtering is presentation, never authorization. */
2
+ export function safeHref(value) {
3
+ const normalized = value.replace(/[\u0000-\u0020\u007f]/g, '');
4
+ const protocol = normalized.match(/^([a-z][a-z\d+.-]*):/i)?.[1].toLowerCase();
5
+ return !protocol || protocol === 'http' || protocol === 'https';
6
+ }
7
+ export function availableDestinations(destinations) {
8
+ return destinations.filter(d => d.visible !== false && d.permitted !== false && safeHref(d.href));
9
+ }
10
+ function path(value) {
11
+ return (value.split(/[?#]/, 1)[0].replace(/\/+$/, '') || '/');
12
+ }
13
+ export function activeDestination(destinations, pathname) {
14
+ const current = path(pathname);
15
+ return availableDestinations(destinations)
16
+ .filter(d => {
17
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(d.href))
18
+ return false;
19
+ const target = path(d.href);
20
+ return current === target || (d.match !== 'exact' && target !== '/' && current.startsWith(`${target}/`));
21
+ })
22
+ .sort((a, b) => path(b.href).length - path(a.href).length)[0];
23
+ }
24
+ export function mobileDestinations(destinations, ids) {
25
+ const registry = new Map(availableDestinations(destinations).map(d => [d.id, d]));
26
+ return [...new Set(ids)].flatMap(id => registry.has(id) ? [registry.get(id)] : []);
27
+ }
28
+ export function shouldHandleNavigation(event) {
29
+ return !event.defaultPrevented && event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
30
+ }
package/dist/theme.css ADDED
@@ -0,0 +1,77 @@
1
+ :root, [data-theme="light"] {
2
+ color-scheme: light;
3
+ --ts-primary:#6750a4; --ts-on-primary:#fff;
4
+ --ts-nav:#f4f3f6; --ts-on-nav:#34313c;
5
+ --ts-selection:#e9e2f5; --ts-on-selection:#483375;
6
+ --ts-surface:#fff; --ts-surface-subtle:#f9f9fb;
7
+ --ts-text:#27242d; --ts-muted:#625e6b; --ts-border:#dedbe4; --ts-focus:#6750a4;
8
+ --ts-font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
9
+ }
10
+ [data-theme="dark"] {
11
+ color-scheme:dark;
12
+ --ts-primary:#d0bcff; --ts-on-primary:#33205d;
13
+ --ts-nav:#211f25; --ts-on-nav:#e5e0ea;
14
+ --ts-selection:#413653; --ts-on-selection:#e9ddff;
15
+ --ts-surface:#19171d; --ts-surface-subtle:#242128;
16
+ --ts-text:#eee9f2; --ts-muted:#beb7c7; --ts-border:#46404e; --ts-focus:#d0bcff;
17
+ }
18
+ .ts-shell { color:var(--ts-text); background:var(--ts-surface); font-family:var(--ts-font); font-size:16px; line-height:1.5; min-height:100dvh; }
19
+ .ts-shell *, .ts-shell *::before, .ts-shell *::after { box-sizing:border-box; }
20
+ .ts-nav-link, .ts-skip, a.ts-action { color:inherit; text-decoration:none; }
21
+ .ts-shell button { font:inherit; cursor:pointer; }
22
+ .ts-shell :focus-visible { outline:3px solid var(--ts-focus); outline-offset:3px; }
23
+ .ts-shell-background { display:grid; grid-template-columns:256px minmax(0,1fr); min-height:100dvh; }
24
+ .ts-skip { position:fixed; top:8px; left:16px; z-index:100; padding:12px; background:var(--ts-surface); transform:translateY(-180%); }
25
+ .ts-skip:focus { transform:none; }
26
+ .ts-sidebar { position:sticky; top:0; align-self:start; height:100dvh; overflow-y:auto; overscroll-behavior:contain; background:var(--ts-nav); color:var(--ts-on-nav); border-right:1px solid var(--ts-border); padding:32px 16px 20px; display:flex; flex-direction:column; gap:24px; }
27
+ .ts-sidebar > * { flex-shrink:0; }
28
+ .ts-brand { display:flex; align-items:center; gap:12px; margin:0 8px 14px; overflow-wrap:anywhere; }
29
+ .ts-brand strong { display:block; font-size:20px; letter-spacing:-.5px; }
30
+ .ts-brand-mark { display:grid; place-items:center; height:40px; width:40px; flex-shrink:0; border:1px solid var(--ts-border); border-radius:12px; font-size:28px; color:var(--ts-primary); font-weight:600; }
31
+ .ts-app-label { display:block; color:var(--ts-muted); font-size:13px; }
32
+ .ts-main-nav,.ts-drawer-nav { display:flex; flex-direction:column; gap:4px; }
33
+ .ts-nav-link { display:flex; align-items:center; gap:12px; min-height:48px; padding:11px 12px; border-radius:10px; font-size:14px; font-weight:500; }
34
+ .ts-nav-link svg { flex-shrink:0; }
35
+ .ts-nav-label { min-width:0; flex:1; overflow-wrap:anywhere; }
36
+ .ts-nav-link:hover { background:var(--ts-selection); }
37
+ .ts-nav-link[aria-current="page"] { background:var(--ts-selection); color:var(--ts-on-selection); font-weight:650; }
38
+ .ts-badge { flex-shrink:0; border:1px solid var(--ts-border); border-radius:6px; padding:0 6px; font-size:12px; min-width:22px; text-align:center; overflow-wrap:anywhere; max-width:45%; }
39
+ .ts-section-label { margin:20px 12px 8px; font-size:11px; letter-spacing:.075em; text-transform:uppercase; color:var(--ts-muted); font-weight:650; overflow-wrap:anywhere; }
40
+ .ts-services { margin-top:auto; padding-top:12px; border-top:1px solid var(--ts-border); }
41
+ .ts-account { padding:12px 4px 0; border-top:1px solid var(--ts-border); }
42
+ .ts-sidebar-note { color:var(--ts-muted); margin:0 12px; font-size:11px; }
43
+ .ts-workspace { min-width:0; }
44
+ .ts-header { min-height:122px; border-bottom:1px solid var(--ts-border); display:flex; gap:24px; align-items:center; justify-content:space-between; padding:28px 40px; }
45
+ .ts-page-heading { min-width:0; }
46
+ .ts-eyebrow { margin:0 0 4px; font-size:12px; color:var(--ts-muted); }
47
+ .ts-header h1 { margin:0; font-size:28px; line-height:1.25; letter-spacing:-.8px; overflow-wrap:anywhere; }
48
+ .ts-header-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
49
+ .ts-page-content { padding:32px 40px 48px; max-width:1280px; margin:0 auto; outline:none; }
50
+ .ts-action { display:inline-flex; justify-content:center; align-items:center; gap:8px; min-height:44px; padding:10px 16px; border:0; border-radius:9px; background:var(--ts-primary); color:var(--ts-on-primary)!important; font-size:14px!important; font-weight:600!important; }
51
+ .ts-action span { overflow-wrap:anywhere; }
52
+ .ts-action svg { flex-shrink:0; }
53
+ .ts-mobile-bar { display:none; }
54
+ .ts-overlay { position:fixed; inset:0; background:rgb(17 14 23 / .48); z-index:40; }
55
+ .ts-drawer { position:fixed; top:0; bottom:0; right:0; width:min(400px,calc(100% - 48px)); background:var(--ts-nav); color:var(--ts-on-nav); z-index:41; padding:20px max(20px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) 20px; overflow-y:auto; overscroll-behavior:contain; }
56
+ .ts-drawer-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
57
+ .ts-drawer-heading h2 { font-size:19px; margin:0; }
58
+ .ts-icon-button { width:44px; height:44px; flex-shrink:0; color:inherit; background:transparent; border:1px solid var(--ts-border); border-radius:9px; }
59
+ .ts-drawer-description { margin:4px 0 24px; color:var(--ts-muted); font-size:13px; }
60
+ .ts-drawer .ts-services { margin-top:24px; }
61
+ .ts-drawer .ts-account { margin-top:24px; }
62
+ @media(max-width:767px) {
63
+ .ts-shell-background { display:block; }
64
+ .ts-sidebar { display:none; }
65
+ .ts-header { padding:24px 20px; min-height:108px; gap:12px; align-items:flex-start; }
66
+ .ts-header h1 { font-size:25px; }
67
+ .ts-header-actions { justify-content:flex-end; }
68
+ .ts-desktop-action { display:none; }
69
+ .ts-page-content { padding:24px 20px var(--ts-mobile-space); }
70
+ .ts-mobile-bar { display:flex; align-items:stretch; gap:2px; position:fixed; bottom:0; left:0; right:0; border-top:1px solid var(--ts-border); background:var(--ts-nav); padding:8px max(8px,env(safe-area-inset-right)) max(8px,env(safe-area-inset-bottom)) max(8px,env(safe-area-inset-left)); z-index:20; }
71
+ .ts-mobile-bar .ts-nav-link,.ts-mobile-bar .ts-action,.ts-menu-trigger { flex:1; min-width:0; min-height:56px; display:flex; flex-direction:column; justify-content:flex-start; align-items:center; gap:5px; padding:8px 3px; font-size:11px!important; text-align:center; border-radius:9px; }
72
+ .ts-mobile-bar .ts-nav-label { flex:0; width:100%; }
73
+ .ts-mobile-bar .ts-badge { font-size:10px; max-width:100%; }
74
+ .ts-mobile-bar .ts-action { margin:0 3px; }
75
+ .ts-menu-trigger { background:transparent; color:var(--ts-on-nav); border:0; }
76
+ }
77
+ @media(prefers-reduced-motion:reduce) { .ts-shell *, .ts-shell *::before, .ts-shell *::after { scroll-behavior:auto!important; animation:none!important; transition:none!important; } }
@@ -0,0 +1,48 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+ export type ShellIcon = Component<{
3
+ size?: number | string;
4
+ 'aria-hidden'?: boolean | 'true' | 'false';
5
+ }>;
6
+ export interface Destination {
7
+ id: string;
8
+ label: string;
9
+ href: string;
10
+ icon?: ShellIcon;
11
+ groupId?: string;
12
+ badge?: string | number;
13
+ visible?: boolean;
14
+ permitted?: boolean;
15
+ match?: 'exact' | 'prefix';
16
+ }
17
+ export interface NavigationGroup {
18
+ id: string;
19
+ label: string;
20
+ }
21
+ export interface ServiceLink {
22
+ id: string;
23
+ label: string;
24
+ href: string;
25
+ icon?: ShellIcon;
26
+ }
27
+ export interface PrimaryAction {
28
+ id: string;
29
+ label: string;
30
+ icon?: ShellIcon;
31
+ href?: string;
32
+ onclick?: (event: MouseEvent) => void;
33
+ }
34
+ export interface AppShellProps {
35
+ brand: string;
36
+ appLabel: string;
37
+ pageTitle: string;
38
+ pathname: string;
39
+ destinations: Destination[];
40
+ groups?: NavigationGroup[];
41
+ mobilePrimaryIds?: string[];
42
+ primaryAction?: PrimaryAction;
43
+ serviceLinks?: ServiceLink[];
44
+ onNavigate?: (event: MouseEvent, destination: Destination) => void;
45
+ children: Snippet;
46
+ pageActions?: Snippet;
47
+ account?: Snippet;
48
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@tendstead/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/tendstead/ui.git"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "type": "module",
14
+ "scripts": {
15
+ "dev": "node scripts/dev.mjs",
16
+ "check": "npm run build:package && svelte-check --tsconfig ./tsconfig.json",
17
+ "build": "npm run build:package && npm run build:demo",
18
+ "test": "npm run test:unit && npm run test:browser",
19
+ "build:package": "svelte-package -i src/lib -o dist",
20
+ "build:demo": "vite build",
21
+ "test:unit": "vitest run",
22
+ "test:browser": "playwright test --project=chromium --project=firefox --project=webkit",
23
+ "preview": "vite preview --host 0.0.0.0",
24
+ "watch:package": "svelte-package -i src/lib -o dist --watch",
25
+ "test:visual": "playwright test --project=visual",
26
+ "test:visual:update": "playwright test --project=visual --update-snapshots=all",
27
+ "test:consumer": "node scripts/consumer-smoke.mjs",
28
+ "verify": "npm run check && npm run build && npm run test:unit && npm run test:consumer && npm run test:browser && npm run test:visual"
29
+ },
30
+ "svelte": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "svelte": "./dist/index.js",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./theme.css": "./dist/theme.css"
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "peerDependencies": {
44
+ "svelte": "^5.20.0"
45
+ },
46
+ "dependencies": {
47
+ "@melt-ui/svelte": "0.86.6"
48
+ },
49
+ "devDependencies": {
50
+ "@axe-core/playwright": "4.14.0",
51
+ "@lucide/svelte": "1.67.0",
52
+ "@playwright/test": "1.63.0",
53
+ "@sveltejs/package": "2.5.8",
54
+ "@sveltejs/vite-plugin-svelte": "6.2.4",
55
+ "svelte": "5.57.3",
56
+ "svelte-check": "4.7.8",
57
+ "typescript": "5.9.3",
58
+ "vite": "7.3.7",
59
+ "vitest": "4.1.12"
60
+ },
61
+ "sideEffects": [
62
+ "**/*.css"
63
+ ]
64
+ }