diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00fec8d..cd2efcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,32 @@ name: Release +# Fires on v*.*.* tags (with prerelease suffixes). Version comes from Directory.Build.props. +# Publishes via NuGet Trusted Publishing (OIDC, no long-lived API key). Setup in docs/RELEASING.md. on: push: tags: - - 'v*.*.*' + - 'v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + inputs: + dry_run: + description: "Pack and validate only — skip nuget push" + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - publish: + release: runs-on: ubuntu-latest if: github.event.repository.fork == false + environment: release + permissions: + contents: write # for creating the GitHub Release + id-token: write # required for the Trusted Publishing OIDC exchange + env: + PUBLISH: ${{ github.event_name == 'push' || inputs.dry_run == false }} steps: - uses: actions/checkout@v4 @@ -18,46 +36,75 @@ jobs: with: dotnet-version: 10.0.x - - name: Extract version from tag + - name: Read version id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + run: | + v=$(dotnet msbuild src/ShellUI.Native.CLI/ShellUI.Native.CLI.csproj -getProperty:Version) + echo "Directory.Build.props version: $v" + echo "VERSION=$v" >> "$GITHUB_OUTPUT" + + - name: Check tag matches version + if: github.event_name == 'push' + run: | + tag="${GITHUB_REF#refs/tags/v}" + if [ "$tag" != "${{ steps.version.outputs.VERSION }}" ]; then + echo "::error::The CLI builds version ${{ steps.version.outputs.VERSION }} but the tag is v$tag. Update Directory.Build.props." + exit 1 + fi - - name: Restore dependencies - run: dotnet restore src/ + - name: Extract release notes for this version + run: bash scripts/extract-release-notes.sh "${{ steps.version.outputs.VERSION }}" > "$RUNNER_TEMP/release-body.md" + + - name: Restore dependencies (src + tests) + run: | + dotnet restore src/ShellUI.Native.CLI/ShellUI.Native.CLI.csproj + dotnet restore tests/ShellUI.Native.Tests/ShellUI.Native.Tests.csproj - name: Build - run: dotnet build src/ --no-restore --configuration Release + run: | + dotnet build src/ShellUI.Native.CLI/ShellUI.Native.CLI.csproj --no-restore --configuration Release + dotnet build tests/ShellUI.Native.Tests/ShellUI.Native.Tests.csproj --no-restore --configuration Release + + - name: Run tests + run: dotnet test tests/ShellUI.Native.Tests/ShellUI.Native.Tests.csproj --no-restore --no-build --configuration Release + # Core and Templates ship inside the CLI tool package; only the CLI is published. - name: Pack CLI tool - run: dotnet pack src/ShellUI.Native.CLI/ShellUI.Native.CLI.csproj --no-restore --configuration Release -o ./nupkg + run: dotnet pack src/ShellUI.Native.CLI/ShellUI.Native.CLI.csproj --no-build --configuration Release -p:ContinuousIntegrationBuild=true -o ./nupkg - - name: Publish CLI to NuGet + # nuget.org locks a version forever after first publish, and --skip-duplicate would skip it silently. + - name: Verify version is not already published + if: env.PUBLISH == 'true' run: | - dotnet nuget push ./nupkg/ShellUI.Native.CLI.*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + v="${{ steps.version.outputs.VERSION }}" + existing=$(curl -sf "https://api.nuget.org/v3-flatcontainer/shellui.native.cli/index.json" | grep -oE '"[^"]*"' | tr -d '"' || true) + if echo "$existing" | grep -Fxiq "$v"; then + echo "::error::ShellUI.Native.CLI $v is already on nuget.org. Bump Directory.Build.props and re-tag." + exit 1 + fi + + # Right before the push: the temporary API key is valid for one hour. + # `user` is the nuget.org profile name (not the email, not the GitHub org), kept as a secret. + - name: Login to NuGet via Trusted Publishing + if: env.PUBLISH == 'true' + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish CLI to NuGet + if: env.PUBLISH == 'true' + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: dotnet nuget push ./nupkg/ShellUI.Native.CLI.*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + if: github.event_name == 'push' + uses: softprops/action-gh-release@v2 with: name: ShellUI Native v${{ steps.version.outputs.VERSION }} - body: | - ## ShellUI Native v${{ steps.version.outputs.VERSION }} - - ### Installation - ```bash - dotnet tool install -g ShellUI.Native.CLI - ``` - - ### Quick Start - ```bash - cd YourMAUIProject - shellui-native init --yes - shellui-native add button input card - ``` - - See the [documentation](https://github.com/shellui-dev/shellui-native) for usage. + body_path: ${{ runner.temp }}/release-body.md draft: false - prerelease: ${{ contains(github.ref, '-') }} + prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }} files: | ./nupkg/*.nupkg - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Directory.Build.props b/Directory.Build.props index 5a98a54..fbff96b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ - 0.0.1 - + 0.1.0 + alpha.1 diff --git a/assets/brand/shelldocs.svg b/assets/brand/shelldocs.svg new file mode 100644 index 0000000..9b82bc2 --- /dev/null +++ b/assets/brand/shelldocs.svg @@ -0,0 +1 @@ + diff --git a/assets/brand/shellicons.svg b/assets/brand/shellicons.svg new file mode 100644 index 0000000..645c843 --- /dev/null +++ b/assets/brand/shellicons.svg @@ -0,0 +1 @@ + diff --git a/assets/brand/shellui.svg b/assets/brand/shellui.svg new file mode 100644 index 0000000..ec06973 --- /dev/null +++ b/assets/brand/shellui.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-dark.png b/assets/icon-dark.png deleted file mode 100644 index 2d5ef9c..0000000 Binary files a/assets/icon-dark.png and /dev/null differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..2bf104d --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000..399f3e1 Binary files /dev/null and b/assets/logo-dark.png differ diff --git a/assets/logo-light.png b/assets/logo-light.png new file mode 100644 index 0000000..865c31c Binary files /dev/null and b/assets/logo-light.png differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c1108f..07dba0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -201,7 +201,15 @@ Not implemented yet — this illustrates the target shape once Template System v All packages share a single version defined in `Directory.Build.props`: ```xml -0.0.1 +0.1.0 +alpha.1 ``` -Component metadata reads this version at runtime, ensuring consistency across all installed components. +The build stamps it into the assemblies, and component metadata reads it from there at runtime, +so `shellui-native.json` records the CLI version each component was installed with. + +**Releasing:** set the version in `Directory.Build.props`, add a `# ShellUI Native v` +section to [RELEASE_NOTES.md](./RELEASE_NOTES.md), merge to `main`, then push a `v` tag. +`.github/workflows/release.yml` checks the tag, runs the tests, publishes `ShellUI.Native.CLI` +through NuGet Trusted Publishing and creates the GitHub release. Setup and steps are in +[RELEASING.md](./RELEASING.md). diff --git a/docs/BLAZOR_HYBRID.md b/docs/BLAZOR_HYBRID.md index e64822f..e1eb5e7 100644 --- a/docs/BLAZOR_HYBRID.md +++ b/docs/BLAZOR_HYBRID.md @@ -65,7 +65,7 @@ shellui init --yes shellui add button input card # Install ShellUI Native (for native MAUI controls) -dotnet tool install -g ShellUI.Native.CLI +dotnet tool install -g ShellUI.Native.CLI --prerelease shellui-native init --yes shellui-native add button dialog ``` diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ba39daa..47a380f 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -1158,6 +1158,10 @@ shellui-native list --available Dependencies install automatically. Almost every component depends on `shell` (theme tokens and core helpers); components that draw icons also depend on `icon`. +Parts of a compositional family (`dialog-trigger`, `accordion-item`, `card-header`, …) find their +parent at runtime, so they only build next to it. Adding a part on its own installs the whole +family: `shellui-native add dialog-trigger` installs `dialog` and all its parts. + | Component | Auto-installs | |-----------|---------------| | button | shell, icon, button-variants | diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 8b0553d..86b4bfd 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -2,9 +2,9 @@ Prioritized list of components to create for ShellUI Native, aligned with [ShellUI Components](https://github.com/shellui/shell-ui) patterns. All components should follow **compositional patterns** using Dependencies (parent + sub-components) instead of monolithic ChildContent. -Last revised: **2026-09-27** (theme tokens, icons and component polish on `feat/p3-navigation-layout`). +Last revised: **2026-10-07** (first prerelease, `0.1.0-alpha.1`). -**Status at a glance:** P0 ✅ done · P1 ✅ done · P2 ✅ done · **P3 ⏭️ next (`feat/p3-navigation-layout`)** · P4–P7 backlog · Avalonia (Phase 2) unblocked, sequenced after P3 to avoid a moving target. +**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2) ⏭️ next**. --- @@ -142,29 +142,29 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc | Component | Status | Dependencies | ShellUI Ref | Notes | |-----------|--------|--------------|-------------|-------| | **textarea** | ✅ Done | — | Textarea | Multi-line text input | -| **select** | ✅ Done | — | Select | Native `Picker` wrapper at 40px baseline. Trigger/Content split deferred until custom-dropdown variant is needed | +| **select** | ✅ Done | — | Select | Custom-drawn trigger + floating list at the 40px baseline (native `Picker` dropped 2026-09-27) | | **slider** | ✅ Done | — | Slider | Range input | | **radio-group** | ✅ Done | radio-group-item | RadioGroup | Radio button group. Item checked-border fixed 2026-08-29 | -| **date-picker** | ✅ Done | — | DatePicker | Date selection | -| **time-picker** | ✅ Done | — | TimePicker | Time selection | +| **date-picker** | ✅ Done | calendar | DatePicker | Custom trigger + floating `calendar` (2026-10-02) | +| **time-picker** | ✅ Done | — | TimePicker | Custom trigger + hour / minute / AM-PM columns (2026-10-04) | --- -### P3 — Medium (Navigation & Layout) ⏭️ Next — `feat/p3-navigation-layout` +### P3 — Medium (Navigation & Layout) ✅ Done — merged in PR #3 *See [DEVELOPMENT_PLAN.md § Phase 1c](./DEVELOPMENT_PLAN.md) for scope, order, and exit criteria.* | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| -| **collapsible** | P3.1 (build first — primitive) | collapsible-trigger, collapsible-content, element-extensions | Collapsible | Single expand/collapse — the `IsOpen` + animation primitive `accordion-item` composes on top of. | -| **accordion** | P3.2 | accordion-item, accordion-trigger, accordion-content, element-extensions | Accordion | Multiple sections. `Type=Single` (radio-style) or `Multiple`. | -| **tabs** | P3.3 | tabs-list, tabs-trigger, tabs-content, element-extensions | Tabs | Tab bar + one visible panel. Trigger MUST render at the 40px baseline (row-level control). | -| **breadcrumb** | P3.4 | breadcrumb-item | Breadcrumb | Nav trail with separator between items. | -| **skeleton** | P3.5 | — | Skeleton | Animated grey block, sized by parent — use for perceived-perf on lists. | -| **scroll-area** | P3.6 | — | ScrollArea | `ScrollView` wrapper. Cross-platform scrollbar styling is thin — consider whether this is worth the wrapper vs documenting native `ScrollView`. | +| **collapsible** ✅ | P3.1 (build first — primitive) | collapsible-trigger, collapsible-content, element-extensions | Collapsible | Single expand/collapse — the `IsOpen` + animation primitive `accordion-item` composes on top of. | +| **accordion** ✅ | P3.2 | accordion-item, accordion-trigger, accordion-content, element-extensions | Accordion | Multiple sections. `Type=Single` (radio-style) or `Multiple`. | +| **tabs** ✅ | P3.3 | tabs-list, tabs-trigger, tabs-content, element-extensions | Tabs | Tab bar + one visible panel. Trigger MUST render at the 40px baseline (row-level control). | +| **breadcrumb** ✅ | P3.4 | breadcrumb-item | Breadcrumb | Nav trail with separator between items. | +| **skeleton** ✅ | P3.5 | — | Skeleton | Animated grey block, sized by parent — use for perceived-perf on lists. | +| **scroll-area** ✅ | P3.6 | — | ScrollArea | `ScrollView` wrapper. Cross-platform scrollbar styling is thin — consider whether this is worth the wrapper vs documenting native `ScrollView`. | --- -### P4 — Medium (Feedback & Overlays) +### P4 — Medium (Feedback & Overlays) ✅ Done | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| | **tooltip** ✅ | P4.1 | shell | Tooltip | Done 2026-10-02 (floats in the page layer) | @@ -175,7 +175,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc --- -### P5 — Lower (Data Display) +### P5 — Lower (Data Display) ✅ Done | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| | **avatar** ✅ | P5.1 | shell, icon | Avatar | Done 2026-09-27 | @@ -187,7 +187,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc --- -### P6 — Lower (Advanced) +### P6 — Lower (Advanced) — partly done | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| | **context-menu** ✅ | P6.1 | shell, icon, element-extensions | ContextMenu | Done 2026-10-05 — one template; opens at the pointer on right-click, under the trigger on long-press | @@ -219,9 +219,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc | link-card ✅ | — | Done 2026-10-05 — tappable card that can open a URL | | aspect-ratio ✅ | — | Done 2026-10-05 | | wrap-layout ✅ | — | Done 2026-10-05 — wrapping layout used by tag-input and the demo's button rows | -| theme-toggle | P7 | Already in demo; consider as component | -| copy-button | P7 | Copy to clipboard | -| toggle | P7 | Toggle button (vs Switch) | +| theme-toggle ✅ | P7 | Done — `ThemeToggle` component | --- @@ -287,28 +285,14 @@ public static T? FindParentOfType(this Element element) where T : Element ## Order of Implementation -**Shipped on `main`** (Phase 1a + 1b — see [DEVELOPMENT_PLAN.md](./DEVELOPMENT_PLAN.md)): -P0 (15 components) → P1 (14 components across dialog / drawer / sheet / dropdown / popover -families) → P2 (6 components including textarea, select, slider, radio-group, date-picker, -time-picker). Total 44 templates, all platform-keyed via Template System v2. - -**Up next — `feat/p3-navigation-layout`** (Phase 1c). Build the primitive before the -composite: - -1. **collapsible** + collapsible-trigger, collapsible-content — establishes the `IsOpen` + - animation pattern the rest of the tier reuses -2. **accordion** + accordion-item, accordion-trigger, accordion-content — accordion-item - is collapsible with sibling coordination via `FindParentOfType()` -3. **tabs** + tabs-list, tabs-trigger, tabs-content — same show/hide pattern as accordion - but always exactly one visible -4. **breadcrumb** + breadcrumb-item — standalone, no shared state -5. **skeleton** — standalone, animation primitive worth locking early so P5 (avatar, - table, empty-state) can compose it -6. **scroll-area** — decide during implementation whether it's worth the wrapper vs - documenting native `ScrollView` - -**After Phase 1c → Phase 2 (Avalonia).** Do not open Phase 2 until Phase 1c merges — the -whole point of finishing P3 on MAUI first is to freeze the vocabulary before it doubles. - -**Then Phase 1d / P4+ (feedback, data display, advanced).** Sequenced after Avalonia has -Avalonia-parity on P0/P1 at least, so new MAUI components don't get too far ahead again. +**Shipped in `0.1.0-alpha.1`** (see [DEVELOPMENT_PLAN.md](./DEVELOPMENT_PLAN.md) and +[RELEASE_NOTES.md](./RELEASE_NOTES.md)): P0–P5, P6 context-menu / carousel / stepper, and the +P7 items marked ✅ above — 58 families, 89 CLI targets, MAUI only. + +**Next — Phase 2 (Avalonia).** Port in the same order as MAUI (P0 → P1 first), reusing the +component APIs, tokens and sizing contract. MAUI is far ahead, so new MAUI components wait +until Avalonia has P0 + P1. + +**MAUI backlog after that:** resizable, navbar, sidebar (P6); chart, file-upload, +date-range-picker (P7); and the ShellUI Blazor targets not yet ported (button-group, +input-group, command / command-palette, menubar, navigation-menu, data-table, …). diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index c57a4c5..4018744 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -4,7 +4,7 @@ Living document tracking **branches**, **phases**, and **feature implementations ShellUI Native. Companion to [PLAN.md](./PLAN.md) (long-term strategy) and [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md) (prioritized component backlog). -Last revised: **2026-08-30** (Phase 1c in flight, P4 polish scoped). +Last revised: **2026-10-07** (Phase 1e merged; `0.1.0-alpha.1` release prep). --- @@ -16,9 +16,9 @@ Short-lived sub-branches (`feat//`) cut off the phase branch and m into it, not directly into `main`. ``` -main ← Phase 1a (2026-07-05), 1b (2026-08-29, PR #2), 1c + 1d (2026-10-05, PR #3) merged - └─ feat/p5-p6-components ← Phase 1e (active) — MAUI P5/P6 tier (table, context menu, carousel, stepper, …) - └─ feat/avalonia-implementation ← Phase 2 (planned) — Avalonia templates + reference impl +main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand mark (PR #5) merged + └─ chore/release-v0.1.0-alpha.1 ← first prerelease (active) + └─ feat/avalonia-implementation ← Phase 2 (next) — Avalonia templates + reference impl └─ feat/winui ← Phase 3 (conditional) ``` @@ -35,8 +35,9 @@ main ← Phase 1a (2026-07-05), 1b (2026-08-29, PR #2), 1c + 1d (2026-10-05, P | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1a–1d merged | 76 platform-keyed components, theme tokens, overlay portal, xUnit 408/408 | -| `feat/p5-p6-components` | `main` | **Active** | Phase 1e — MAUI P5/P6 (table, context-menu, carousel, stepper; 80 components, xUnit 428/428) | +| `main` | — | Phase 1a–1e merged | 92 registry entries (89 CLI targets, 58 families), MAUI only | +| `chore/release-v0.1.0-alpha.1` | `main` | **Active** | Version `0.1.0-alpha.1`, fixed release workflow, release notes, docs refresh, per-component CLI check | +| `feat/avalonia-implementation` | `main` | Next | Phase 2 | --- @@ -379,7 +380,7 @@ Date/Time pickers inside a themed border with the native frame stripped. --- -## Phase 1e — `feat/p5-p6-components` (active) +## Phase 1e — `feat/p5-p6-components` (**merged via [PR #4](https://github.com/shellui-dev/shellui-native/pull/4)**) The remaining MAUI data-display and advanced tiers, built demo-first like Phase 1d. @@ -402,9 +403,9 @@ The remaining MAUI data-display and advanced tiers, built demo-first like Phase and the tag-input keyboard / height - [x] `multi-select`, `tree-view`, `copy-button`, `link-card`, `aspect-ratio` — run on Windows and Android -- [ ] iOS / Mac Catalyst pass for everything -- [ ] Escape closes the overlay on top on Mac Catalyst -- [ ] `resizable`, `navbar`, `sidebar` (P6.4–P6.6) +- [ ] iOS / Mac Catalyst pass for everything (carried over) +- [ ] Escape closes the overlay on top on Mac Catalyst (carried over) +- [ ] `resizable`, `navbar`, `sidebar` (P6.4–P6.6) (moved to the MAUI backlog, after Phase 2 starts) Each family ships as one template (e.g. `table` holds Table, TableHeader, TableRow, TableHead and TableCell) rather than one template per part. @@ -417,15 +418,32 @@ and TableCell) rather than one template per part. --- -## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1e) +## Release `0.1.0-alpha.1` — `chore/release-v0.1.0-alpha.1` (active) + +First NuGet prerelease of `ShellUI.Native.CLI`, MAUI only. + +- [x] `Directory.Build.props` → `0.1.0` + `alpha.1` +- [x] Component versions read from the assembly (an installed tool has no `Directory.Build.props`) +- [x] `release.yml` builds the CLI and tests (not the bare `src/` folder), checks the tag against + the props version, runs the tests and uses [RELEASE_NOTES.md](./RELEASE_NOTES.md) as the + GitHub release body (`scripts/extract-release-notes.sh`); publishes through NuGet Trusted + Publishing like ShellDocs (setup in [RELEASING.md](./RELEASING.md)) +- [x] Install docs use `--prerelease`; roadmap and plan brought up to date +- [x] Every CLI target added alone to a fresh MAUI library, plus all together in a fresh MAUI app + +After merge: tag `v0.1.0-alpha.1` on `main` and push the tag. + +--- + +## Phase 2 — `feat/avalonia-implementation` (next, after `0.1.0-alpha.1`) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. ### Deliverables -- [ ] New `src/ShellUI.Native.Avalonia/` reference project (Avalonia 11.x) +- [ ] New `src/ShellUI.Native.Avalonia/` reference project (Avalonia 12.x) - [ ] Avalonia design-token `ResourceDictionary` mirroring [`StyleTemplates.cs`](../src/ShellUI.Native.Templates/StyleTemplates.cs) -- [ ] Add `AvaloniaContent` to each of the ~40 templates, using the +- [ ] Add Avalonia content to each template (P0 + P1 first), using the `TemplatedControl`/`StyledProperty` pattern documented in [ARCHITECTURE.md § Component Pattern (Avalonia)](./ARCHITECTURE.md) - [ ] `examples/Avalonia.Demo/` reference app mirroring the MAUI demo diff --git a/docs/PLAN.md b/docs/PLAN.md index 7ec579b..67d1124 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -25,8 +25,8 @@ ShellUI Native brings the same beautiful, accessible, and customizable component ### Phase 1: MAUI (Primary Focus) - **Target:** .NET MAUI (iOS, Android, Windows, macOS) on .NET 10 - **Components:** 50+ native controls -- **Status:** In Development — foundational set (~40 components) landed on - `feat/initial-foundation`; overlay + form completeness tracked in +- **Status:** First prerelease `0.1.0-alpha.1` — 58 component families (89 CLI targets), + P0–P5 complete, P6 partly; remaining work tracked in [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md). ### Phase 2: Avalonia UI @@ -140,14 +140,14 @@ regardless of detected platform (see ### Installation ```bash -dotnet tool install -g ShellUI.Native.CLI +dotnet tool install -g ShellUI.Native.CLI --prerelease ``` ### Commands ```bash shellui-native init # Initialize project (creates shellui-native.json) shellui-native add # Add components (copies to Components/UI/) -shellui-native list # List available components (70+ components) +shellui-native list # List available components (89 targets) shellui-native remove # Remove components shellui-native update # Update components to latest version ``` @@ -162,7 +162,7 @@ shellui-native update # Update components to latest version "installedComponents": [ { "name": "button", - "version": "0.1.0", + "version": "0.1.0-alpha.1", "platform": "MAUI", "installedAt": "2026-01-11T...", "isCustomized": false @@ -176,7 +176,7 @@ shellui-native update # Update components to latest version ### For MAUI Projects ```bash # Install CLI -dotnet tool install -g ShellUI.Native.CLI +dotnet tool install -g ShellUI.Native.CLI --prerelease # Create new MAUI project dotnet new maui -n MyApp @@ -219,7 +219,7 @@ dotnet run ### .NET Version Support - **MAUI:** .NET 10.0 (unified across all libraries — see [global.json](../global.json)) -- **Avalonia:** .NET 10.0 (Avalonia 11+) +- **Avalonia:** .NET 10.0 (Avalonia 12) - **WinUI:** .NET 10.0 (WinUI 3) — conditional phase ### Platform Requirements diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 3564771..5de469e 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -5,14 +5,16 @@ Get started with ShellUI Native in under 5 minutes. ## Prerequisites - .NET 10.0 SDK (see [global.json](../global.json)) -- A MAUI project today; Avalonia support is landing in Phase 2 (see [DEVELOPMENT_PLAN.md](./DEVELOPMENT_PLAN.md)) +- A .NET MAUI project (`dotnet new maui`); Avalonia support is the next phase (see [DEVELOPMENT_PLAN.md](./DEVELOPMENT_PLAN.md)) ## Installation ### 1. Install the CLI Tool +ShellUI Native is in prerelease, so include `--prerelease`: + ```bash -dotnet tool install -g ShellUI.Native.CLI +dotnet tool install -g ShellUI.Native.CLI --prerelease ``` ### 2. Navigate to Your Project @@ -34,8 +36,8 @@ This will: - Create `shellui-native.json` configuration - Generate theme resources (MAUI) -> If the CLI detects Avalonia/WinUI/WPF, `shellui-native add` will print a warning and still -> install MAUI-flavored code, since per-platform templates are the Phase 1b / Phase 2 workstream. +> Templates exist for MAUI only so far. In an Avalonia, WinUI or WPF project, `init` skips the +> `Shell.cs` utility and `add` reports that the component has no template for that platform. ### 4. Add Components @@ -50,18 +52,34 @@ shellui-native add button input card shellui-native add button,input,card ``` -### 5. Use Components in Your App +### 5. Load the Theme + +Publish the theme tokens before the first page loads, in `App.xaml.cs`: + +```csharp +public App() +{ + InitializeComponent(); + Components.UI.ShellTheme.EnsureInitialized(); +} +``` + +Components also do this on first use, but calling it early lets your own +`{DynamicResource ShellUI*}` references resolve on the first page. See +[COMPONENTS.md § Theming](./COMPONENTS.md#theming) for switching and customizing the theme. + +### 6. Use Components in Your App ```xml - - + + - - + ``` diff --git a/docs/README.md b/docs/README.md index 29668c6..a094598 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,25 +1,22 @@ -
- - - - - - -``` -███████╗██╗ ██╗███████╗██╗ ██╗ ██╗ ██╗██╗ ███╗ ██╗ █████╗ ████████╗██╗██╗ ██╗███████╗ -██╔════╝██║ ██║██╔════╝██║ ██║ ██║ ██║██║ ████╗ ██║██╔══██╗╚══██╔══╝██║██║ ██║██╔════╝ -███████╗███████║█████╗ ██║ ██║ ██║ ██║██║ ██████╗ ██╔██╗ ██║███████║ ██║ ██║██║ ██║█████╗ -╚════██║██╔══██║██╔══╝ ██║ ██║ ██║ ██║██║ ╚═════╝ ██║╚██╗██║██╔══██║ ██║ ██║╚██╗ ██╔╝██╔══╝ -███████║██║ ██║███████╗███████╗███████╗╚██████╔╝██║ ██║ ╚████║██║ ██║ ██║ ██║ ╚████╔╝ ███████╗ -╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ -``` - -**One design system, every platform** - -[![NuGet](https://img.shields.io/nuget/v/ShellUI.Native.CLI.svg)](https://www.nuget.org/packages/ShellUI.Native.CLI) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](../LICENSE) - -
+

+ + + + ShellUI Native logo + +

+ +

ShellUI Native

+ +

+ One design system, every platform.
+ Copy-and-own native components for .NET MAUI, inspired by shadcn/ui. +

+ +

+ ShellUI.Native.CLI on NuGet + MIT license +

ShellUI Native is the native cross-platform extension of [ShellUI](https://shellui.dev/), bringing the same design system and component philosophy to MAUI, Avalonia, and other native platforms. Inspired by [shadcn/ui](https://ui.shadcn.com/)'s approach, ShellUI Native provides copy-and-own components for native desktop and mobile development. @@ -39,10 +36,10 @@ Unlike [ShellUI Blazor](https://shellui.dev/), **ShellUI Native does not use Tai ## Quick Start ```bash -# Install the CLI tool -dotnet tool install -g ShellUI.Native.CLI +# Install the CLI tool (prerelease) +dotnet tool install -g ShellUI.Native.CLI --prerelease -# Initialize in your MAUI or Avalonia project +# Initialize in your MAUI project shellui-native init --yes # Add components @@ -56,8 +53,8 @@ shellui-native list | Platform | Status | .NET Version | |----------|--------|--------------| -| .NET MAUI | Active (Phase 1) | .NET 10.0 | -| Avalonia UI | Planned (Phase 2) | .NET 10.0 (Avalonia 11+) | +| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 | +| Avalonia UI | Planned (Phase 2) | .NET 10.0 (Avalonia 12) | | WinUI 3 | Conditional (Phase 3) | .NET 10.0 | WPF is intentionally not on this list — it's recognized for project detection only, not an @@ -88,6 +85,8 @@ They're separate rendering contexts with no conflict. Install what you need base - [Getting Started](./QUICKSTART.md) - [Component List](./COMPONENTS.md) +- [Release Notes](./RELEASE_NOTES.md) +- [Releasing](./RELEASING.md) — how a version is published - [Components Roadmap](./COMPONENTS_ROADMAP.md) — prioritized P0–P7 backlog - [Development Plan](./DEVELOPMENT_PLAN.md) — branch strategy & phase breakdown - [Architecture](./ARCHITECTURE.md) @@ -99,14 +98,10 @@ They're separate rendering contexts with no conflict. Install what you need base - - - + + - - - + @@ -129,13 +124,21 @@ YourProject/ │ ├── Variants/ │ │ └── ButtonVariants.cs │ └── Shell.cs +├── Resources/ +│ └── Styles/ +│ └── ShellUITheme.xaml └── shellui-native.json ``` -## Related Projects +## The Shell family + +| | Project | | +|---|---|---| +| | [ShellUI](https://github.com/shellui-dev/shellui) | The Blazor component library this one mirrors | +| | [ShellIcons](https://github.com/shellui-dev/shell-icons) | Lucide icons for Blazor, MAUI and Avalonia; the source of the `icon` component | +| | [ShellDocs](https://github.com/shellui-dev/shelldocs) | The docs framework behind the ShellUI sites | -- [ShellUI (Blazor)](https://github.com/shellui-dev/shellui) - Blazor component library -- [shadcn/ui](https://ui.shadcn.com/) - The original inspiration +Inspired by [shadcn/ui](https://ui.shadcn.com/). ## License diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..58f8e07 --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,57 @@ +# ShellUI Native Release Notes + +# ShellUI Native v0.1.0-alpha.1 🧪 + +> The first public prerelease of ShellUI Native: shadcn-style, copy-and-own components for .NET MAUI, with the same design tokens as [ShellUI](https://shellui.dev/) for Blazor. It is a prerelease, so install with `--prerelease`. Report issues via [GitHub Issues](https://github.com/shellui-dev/shellui-native/issues). + +## 📦 Install + +```bash +dotnet tool install -g ShellUI.Native.CLI --prerelease + +cd YourMauiApp +shellui-native init --yes +shellui-native add button input card +``` + +Requires the .NET 10 SDK and a .NET MAUI project. Components are plain C# files written to `Components/UI/`; edit them freely. + +## ✨ Components + +58 component families, 89 CLI targets. Parts such as `dialog-trigger` belong to a family; adding one installs the whole family. + +| Category | Components | +|---|---| +| Form | `button`, `input`, `label`, `textarea`, `checkbox`, `switch`, `radio-group`, `select`, `combobox`, `multi-select`, `slider`, `date-picker`, `time-picker`, `calendar`, `input-otp`, `number-input`, `tag-input`, `toggle`, `toggle-group` | +| Layout | `card`, `separator`, `accordion`, `collapsible`, `scroll-area`, `aspect-ratio`, `wrap-layout` | +| Feedback | `alert`, `callout`, `toast`, `spinner`, `skeleton`, `progress` | +| Overlay | `dialog`, `alert-dialog`, `drawer`, `sheet`, `dropdown`, `popover`, `hover-card`, `tooltip`, `context-menu` | +| Navigation | `tabs`, `breadcrumb`, `pagination`, `stepper`, `tree-view`, `link-card` | +| Data display | `badge`, `avatar`, `table`, `empty-state`, `stat-card`, `timeline`, `carousel`, `kbd` | +| Utility | `icon`, `theme-toggle`, `copy-button` | + +Compositional families (`dialog`, `drawer`, `sheet`, `dropdown`, `popover`, `hover-card`, `accordion`, `collapsible`, `tabs`, `breadcrumb`, `card`, `radio-group`) follow the shadcn Trigger/Content pattern and install their parts automatically. + +## 🎨 Design system + +- **Theme tokens:** `ShellTheme` holds light and dark palettes keyed like ShellUI's CSS variables (`Background`, `Primary`, `Border`, …) and publishes them as `ShellUI` / `ShellUIBrush` resources. Switching theme repaints every component; tokens can be overridden at startup. +- **Sizing:** every single-line form control is 40px high, matching shadcn's `h-10`. +- **Icons:** about 110 Lucide icons (from ShellIcons) drawn with MAUI shapes, with no icon font or package. + +## 🪟 Overlays + +- Dialogs, drawers, sheets, menus, selects, tooltips, hover cards and toasts float in a page-level layer (`ShellPortal`), so you can declare them next to their trigger, inside a `ScrollView`, and they are never clipped. +- Select, Combobox, Date Picker and Time Picker are custom-drawn, with no native picker chrome. +- Escape on Windows and the back button on Android close the overlay on top (`ShellDismiss`). +- Optional `ShellTheme.SyncSystemBars` makes the Android status and navigation bars follow the theme. + +## 🧰 CLI + +`shellui-native init`, `add`, `list`, `remove` and `update`. `init` writes the `shell` utility, `shellui-native.json` and a theme resource dictionary; `add` resolves dependencies and replaces the namespace with your project's. In a terminal, `init` and `list` show the ShellUI Native mark, and work in progress uses ShellUI's logo and snake loaders. + +## ⚠️ Known limitations + +- **MAUI only.** Avalonia is the next phase; WinUI 3 is conditional. On those projects `init` and `add` say the components have no template for that platform yet. +- Tested on Windows and Android. iOS and Mac Catalyst build but have not been run on a device; Escape does not close overlays on Mac Catalyst yet. +- Very large pages on Windows can hit WinUI's layout-pass limit (`Layout cycle detected`). Split the page or keep sections hidden until needed; see [COMPONENTS.md](https://github.com/shellui-dev/shellui-native/blob/main/docs/COMPONENTS.md#long-pages-on-windows). +- The docs site (native.shellui.dev) is not live yet; see [COMPONENTS.md](https://github.com/shellui-dev/shellui-native/blob/main/docs/COMPONENTS.md) for usage. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..cf3fe34 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,59 @@ +# Releasing ShellUI Native + +Runbook for publishing `ShellUI.Native.CLI` to NuGet. Same setup as ShellDocs. + +## One-time setup + +Publishing uses **NuGet Trusted Publishing**: each run of `.github/workflows/release.yml` exchanges a GitHub OIDC token for a NuGet API key that lasts one hour. No long-lived API key is stored. Official docs: . + +### 1. Create the `release` environment on GitHub + +Repo → Settings → Environments → **New environment** → name it exactly `release`. Nothing else is required (optionally add required reviewers for a manual gate on each publish). + +### 2. Add a Trusted Publishing policy on nuget.org + +Sign in → your username → **Trusted Publishing** → **Add**, choose the owner of the package, then (case-insensitive): + +| Field | Value | +|---|---| +| Repository Owner | `shellui-dev` | +| Repository | `shellui-native` | +| Workflow File | `release.yml` (file name only, no `.github/workflows/`) | +| Environment | `release` (must match `environment: release` in the workflow) | + +A policy for a private repository starts as provisional for 7 days; the first successful publish locks it to the repository. If nothing is published in 7 days it goes inactive and can be restarted. + +### 3. Add the `NUGET_USER` secret + +Repo → Settings → Secrets and variables → Actions → **New repository secret** (or add it to the `release` environment): + +| Name | Value | +|---|---| +| `NUGET_USER` | Your nuget.org profile name, as in `nuget.org/profiles/`; not the email and not the GitHub org | + +No `NUGET_API_KEY` secret is needed; delete an old one if it exists. + +## Releasing a version + +1. Set the version in `Directory.Build.props` (`ShellUINativeVersion` and `ShellUINativeVersionSuffix`). +2. Add a `# ShellUI Native v` section to [RELEASE_NOTES.md](./RELEASE_NOTES.md). It becomes the GitHub release text. +3. Merge to `main`, then tag and push: + + ```bash + git switch main && git pull --ff-only + git tag -a v -m "ShellUI Native v" + git push origin v + ``` + +The tag push runs the workflow: it checks the tag matches the props version, extracts the release notes, builds, runs the tests, packs the CLI, stops if that version is already on nuget.org, logs in via Trusted Publishing, pushes the package and creates the GitHub release (a prerelease when the version has a `-`). + +If the login step fails, check in this order: `NUGET_USER` missing or wrong, the policy's Workflow File has a path prefix, the policy's Environment doesn't match `release`, the policy expired (provisional, private repo). + +## Dry run + +Actions → Release → **Run workflow** with "Pack and validate only" checked: runs the version, release-notes, build, test and pack steps and skips the version check, login, push and GitHub release. + +## After the release + +- The package shows at after indexing (a few minutes). +- Check the install in a scratch folder: `dotnet tool install -g ShellUI.Native.CLI --prerelease`. diff --git a/prepare-release.ps1 b/prepare-release.ps1 index c7b6f27..ab43261 100644 --- a/prepare-release.ps1 +++ b/prepare-release.ps1 @@ -22,40 +22,30 @@ $content = $content -replace '[^<]* [notes-file] +set -euo pipefail + +version="${1:?usage: extract-release-notes.sh [notes-file]}" +file="${2:-docs/RELEASE_NOTES.md}" + +if ! awk -v ver="v$version" ' + { line = $0; sub(/\r$/, "", line) } + line ~ /^```/ { fence = !fence } + !fence && line ~ /^# ShellUI Native v/ { + if (found) exit + split(line, parts, " ") + if (parts[4] == ver) found = 1 + } + found { buf[++n] = $0 } + END { + if (!found) exit 1 + while (n > 0) { + last = buf[n]; sub(/\r$/, "", last) + if (last ~ /^[[:space:]]*(---)?[[:space:]]*$/) n--; else break + } + for (i = 1; i <= n; i++) print buf[i] + } +' "$file"; then + echo "No '# ShellUI Native v$version' section in $file — add release notes before tagging." >&2 + exit 1 +fi diff --git a/src/ShellUI.Native.CLI/Program.cs b/src/ShellUI.Native.CLI/Program.cs index 2d5e751..830ffbd 100644 --- a/src/ShellUI.Native.CLI/Program.cs +++ b/src/ShellUI.Native.CLI/Program.cs @@ -10,6 +10,9 @@ class Program { static async Task Main(string[] args) { + // The loaders draw Unicode dots and Braille. + Console.OutputEncoding = System.Text.Encoding.UTF8; + var rootCommand = new RootCommand("ShellUI Native - CLI-first cross-platform component library") { Description = "Add beautiful, accessible components to your MAUI or Avalonia app. Inspired by shadcn/ui." @@ -27,7 +30,7 @@ static async Task Main(string[] args) static Command CreateInitCommand() { var command = new Command("init", "Initialize ShellUI Native in your project"); - + var forceOption = new Option("--force", "Reinitialize even if already initialized"); var styleOption = new Option("--style", () => "default", "Choose component style (default, minimal)"); var nonInteractiveOption = new Option("--yes", "Run in non-interactive mode with default options"); @@ -40,22 +43,7 @@ static Command CreateInitCommand() { try { - Console.OutputEncoding = System.Text.Encoding.UTF8; - var logo = @" - ███████╗██╗ ██╗███████╗██╗ ██╗ ██╗ ██╗██╗ - ██╔════╝██║ ██║██╔════╝██║ ██║ ██║ ██║██║ - ███████╗███████║█████╗ ██║ ██║ ██║ ██║██║ - ╚════██║██╔══██║██╔══╝ ██║ ██║ ██║ ██║██║ - ███████║██║ ██║███████╗███████╗███████╗╚██████╔╝██║ - ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ - ███╗ ██╗ █████╗ ████████╗██╗██╗ ██╗███████╗ - ████╗ ██║██╔══██╗╚══██╔══╝██║██║ ██║██╔════╝ - ██╔██╗ ██║███████║ ██║ ██║██║ ██║█████╗ - ██║╚██╗██║██╔══██║ ██║ ██║╚██╗ ██╔╝██╔══╝ - ██║ ╚████║██║ ██║ ██║ ██║ ╚████╔╝ ███████╗ - ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ -"; - AnsiConsole.MarkupLine($"[blue]{logo}[/]"); + LogoLoader.WriteHeader("Setting up your MAUI project"); await InitService.InitializeAsync(style, force, nonInteractive); } catch (Exception ex) @@ -70,7 +58,7 @@ static Command CreateInitCommand() static Command CreateAddCommand() { var command = new Command("add", "Add component(s) to your project"); - + var componentsArg = new Argument("components", "Component name(s) to add (space or comma-separated)") { Arity = ArgumentArity.OneOrMore @@ -101,7 +89,7 @@ static Command CreateListCommand() var installedOption = new Option("--installed", "Show only installed components"); var availableOption = new Option("--available", "Show only available components"); - + command.AddOption(installedOption); command.AddOption(availableOption); @@ -123,7 +111,7 @@ static Command CreateListCommand() static Command CreateRemoveCommand() { var command = new Command("remove", "Remove component(s) from your project"); - + var componentsArg = new Argument("components", "Component name(s) to remove") { Arity = ArgumentArity.OneOrMore @@ -148,7 +136,7 @@ static Command CreateRemoveCommand() static Command CreateUpdateCommand() { var command = new Command("update", "Update component(s) to latest version"); - + var componentsArg = new Argument("components", "Component name(s) to update (empty = all)") { Arity = ArgumentArity.ZeroOrMore diff --git a/src/ShellUI.Native.CLI/README.md b/src/ShellUI.Native.CLI/README.md index e0c6041..fea6a1a 100644 --- a/src/ShellUI.Native.CLI/README.md +++ b/src/ShellUI.Native.CLI/README.md @@ -1,18 +1,17 @@ - - - - +![ShellUI Native](https://raw.githubusercontent.com/shellui-dev/shellui-native/main/assets/icon.svg) # ShellUI Native CLI -Command-line interface for ShellUI Native component library. +Command-line interface for ShellUI Native: shadcn-style, copy-and-own components for .NET MAUI, with the same design tokens as [ShellUI](https://shellui.dev/) for Blazor. ## Installation ```bash -dotnet tool install -g ShellUI.Native.CLI +dotnet tool install -g ShellUI.Native.CLI --prerelease ``` +Requires the .NET 10 SDK. + ## Commands ```bash @@ -26,7 +25,7 @@ shellui-native update [--all] # Update components ## Quick Start ```bash -# Navigate to your MAUI/WinUI/WPF project +# Navigate to your MAUI project cd YourProject # Initialize ShellUI Native @@ -36,6 +35,18 @@ shellui-native init --yes shellui-native add button input card ``` +Components are written to `Components/UI/` as plain C# files you own. Avalonia support is the next phase. + ## More Information -See the full documentation at [native.shellui.dev](https://native.shellui.dev) +- [Component reference](https://github.com/shellui-dev/shellui-native/blob/main/docs/COMPONENTS.md) +- [Release notes](https://github.com/shellui-dev/shellui-native/blob/main/docs/RELEASE_NOTES.md) +- Docs site: [native.shellui.dev](https://native.shellui.dev) (coming soon) + +## The Shell family + +| | Project | | +|---|---|---| +| ![](https://raw.githubusercontent.com/shellui-dev/shellui-native/main/assets/brand/shellui.svg) | [ShellUI](https://www.nuget.org/packages/ShellUI.CLI) | The Blazor component library this one mirrors | +| ![](https://raw.githubusercontent.com/shellui-dev/shellui-native/main/assets/brand/shellicons.svg) | [ShellIcons](https://www.nuget.org/packages/ShellIcons.Blazor) | Lucide icons for Blazor, MAUI and Avalonia | +| ![](https://raw.githubusercontent.com/shellui-dev/shellui-native/main/assets/brand/shelldocs.svg) | [ShellDocs](https://www.nuget.org/packages/ShellDocs.CLI) | The docs framework behind the ShellUI sites | diff --git a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs index 73b8550..ad21826 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs @@ -11,7 +11,7 @@ public static class ComponentInstaller public static async Task InstallComponents(string[] components, bool force) { var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui-native.json"); - + if (!File.Exists(configPath)) { AnsiConsole.MarkupLine("[red]ShellUI Native not initialized![/]"); @@ -21,7 +21,7 @@ public static async Task InstallComponents(string[] components, bool force) var configJson = await File.ReadAllTextAsync(configPath); var config = JsonSerializer.Deserialize(configJson); - + if (config == null) { AnsiConsole.MarkupLine("[red]Failed to read shellui-native.json[/]"); @@ -30,18 +30,22 @@ public static async Task InstallComponents(string[] components, bool force) var projectInfo = ProjectDetector.DetectProject(); - // Parse comma-separated components + // Parse comma-separated components; a part installs its whole family var componentList = new List(); - foreach (var comp in components) + foreach (var comp in components.SelectMany(c => c.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) { - componentList.AddRange(comp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + var family = ComponentRegistry.GetFamily(comp); + if (family != null) + AnsiConsole.MarkupLine($"[dim]'{comp}' is part of '{family}', installing '{family}'[/]"); + var name = family ?? comp; + if (!componentList.Contains(name)) + componentList.Add(name); } - var successCount = 0; - var skippedCount = 0; + var tally = new InstallTally(); var failedComponents = new List(); var installedSet = new HashSet(); - + // Show dependency information foreach (var componentName in componentList) { @@ -51,20 +55,18 @@ public static async Task InstallComponents(string[] components, bool force) AnsiConsole.MarkupLine($"[green]●[/] [bold]{componentName}[/] requires: [yellow]{string.Join(", ", metadata.Dependencies)}[/]"); } } - + AnsiConsole.MarkupLine(""); - - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) + + await Loaders.SnakeStatus() .StartAsync("Installing components...", async ctx => { foreach (var componentName in componentList) { ctx.Status($"Installing {componentName}..."); await InstallComponentWithDependenciesAsync( - componentName, config, projectInfo, force, - installedSet, successCount, skippedCount, failedComponents); + componentName, config, projectInfo, force, + installedSet, tally, failedComponents); } }); @@ -74,27 +76,26 @@ await InstallComponentWithDependenciesAsync( // Summary AnsiConsole.MarkupLine(""); - if (successCount > 0) - AnsiConsole.MarkupLine($"[green]Installed {successCount} component(s) successfully![/]"); - if (skippedCount > 0) - AnsiConsole.MarkupLine($"[yellow]Skipped {skippedCount} component(s) (already exists, use --force to overwrite)[/]"); + if (tally.Success > 0) + AnsiConsole.MarkupLine($"[green]Installed {tally.Success} component(s) successfully![/]"); + if (tally.Skipped > 0) + AnsiConsole.MarkupLine($"[yellow]Skipped {tally.Skipped} component(s) (already exists, use --force to overwrite)[/]"); if (failedComponents.Count > 0) AnsiConsole.MarkupLine($"[red]Failed: {string.Join(", ", failedComponents)}[/]"); } private static async Task InstallComponentWithDependenciesAsync( - string componentName, - ShellUINativeConfig config, - ProjectInfo projectInfo, + string componentName, + ShellUINativeConfig config, + ProjectInfo projectInfo, bool force, HashSet installedSet, - int successCount, - int skippedCount, + InstallTally tally, List failedComponents) { if (installedSet.Contains(componentName)) return; - + if (!ComponentRegistry.Exists(componentName)) { AnsiConsole.MarkupLine($"[red]Component '{componentName}' not found[/]"); @@ -118,22 +119,22 @@ private static async Task InstallComponentWithDependenciesAsync( { if (!installedSet.Contains(dep)) { - await InstallComponentWithDependenciesAsync(dep, config, projectInfo, force, installedSet, successCount, skippedCount, failedComponents); + await InstallComponentWithDependenciesAsync(dep, config, projectInfo, force, installedSet, tally, failedComponents); } } } // Install the component var result = await InstallComponentInternalAsync(componentName, metadata, config, projectInfo, force); - + if (result == InstallResult.Success) { - successCount++; + tally.Success++; installedSet.Add(componentName); } else if (result == InstallResult.Skipped) { - skippedCount++; + tally.Skipped++; installedSet.Add(componentName); } else @@ -150,7 +151,7 @@ private static async Task InstallComponentInternalAsync( bool force) { var componentPath = Path.Combine(Directory.GetCurrentDirectory(), config.ComponentsPath, metadata.FilePath); - + if (File.Exists(componentPath) && !force) { AnsiConsole.MarkupLine($"[yellow]Skipped '{componentName}' (already exists)[/]"); @@ -216,4 +217,10 @@ private enum InstallResult Skipped, Failed } + + private sealed class InstallTally + { + public int Success; + public int Skipped; + } } diff --git a/src/ShellUI.Native.CLI/Services/ComponentManager.cs b/src/ShellUI.Native.CLI/Services/ComponentManager.cs index f1f2d92..4b74496 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentManager.cs @@ -36,6 +36,8 @@ public static void ListComponents(bool installedOnly, bool availableOnly) components = components.Where(c => !installedNames.Contains(c.Name)).OrderBy(c => c.Category).ThenBy(c => c.Name); } + LogoLoader.WriteHeader("Components"); + var table = new Table(); table.AddColumn("Component"); table.AddColumn("Category"); @@ -44,15 +46,15 @@ public static void ListComponents(bool installedOnly, bool availableOnly) foreach (var component in components) { - var status = installedNames.Contains(component.Name) - ? "[green]installed[/]" + var status = installedNames.Contains(component.Name) + ? "[green]installed[/]" : "[dim]available[/]"; table.AddRow( $"[bold]{component.Name}[/]", component.Category.ToString(), - component.Description.Length > 40 - ? component.Description[..37] + "..." + component.Description.Length > 40 + ? component.Description[..37] + "..." : component.Description, status ); @@ -65,7 +67,7 @@ public static void ListComponents(bool installedOnly, bool availableOnly) public static void RemoveComponents(string[] components) { var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui-native.json"); - + if (!File.Exists(configPath)) { AnsiConsole.MarkupLine("[red]ShellUI Native not initialized![/]"); @@ -74,7 +76,7 @@ public static void RemoveComponents(string[] components) var json = File.ReadAllText(configPath); var config = JsonSerializer.Deserialize(json); - + if (config == null) { AnsiConsole.MarkupLine("[red]Failed to read shellui-native.json[/]"); @@ -82,7 +84,7 @@ public static void RemoveComponents(string[] components) } var removedCount = 0; - + foreach (var componentName in components) { var metadata = ComponentRegistry.GetMetadata(componentName); @@ -93,7 +95,7 @@ public static void RemoveComponents(string[] components) } var componentPath = Path.Combine(Directory.GetCurrentDirectory(), config.ComponentsPath, metadata.FilePath); - + if (File.Exists(componentPath)) { File.Delete(componentPath); @@ -117,7 +119,7 @@ public static void RemoveComponents(string[] components) public static void UpdateComponents(string[] components, bool all) { var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui-native.json"); - + if (!File.Exists(configPath)) { AnsiConsole.MarkupLine("[red]ShellUI Native not initialized![/]"); @@ -126,7 +128,7 @@ public static void UpdateComponents(string[] components, bool all) var json = File.ReadAllText(configPath); var config = JsonSerializer.Deserialize(json); - + if (config == null) { AnsiConsole.MarkupLine("[red]Failed to read shellui-native.json[/]"); @@ -136,7 +138,7 @@ public static void UpdateComponents(string[] components, bool all) var projectInfo = ProjectDetector.DetectProject(); IEnumerable componentsToUpdate; - + if (all || components.Length == 0) { componentsToUpdate = config.InstalledComponents.Select(c => c.Name); diff --git a/src/ShellUI.Native.CLI/Services/InitService.cs b/src/ShellUI.Native.CLI/Services/InitService.cs index ab0bcc3..08e766d 100644 --- a/src/ShellUI.Native.CLI/Services/InitService.cs +++ b/src/ShellUI.Native.CLI/Services/InitService.cs @@ -19,40 +19,18 @@ public static async Task InitializeAsync(string style, bool force, bool nonInter return; } - ProjectInfo projectInfo = null!; - - try - { - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync("Initializing ShellUI Native...", async ctx => - { - ctx.Status("Detecting project type..."); - await Task.Delay(300); - projectInfo = ProjectDetector.DetectProject(); - - var platformName = projectInfo.Platform switch - { - NativePlatform.MAUI => ".NET MAUI", - NativePlatform.Avalonia => "Avalonia UI", - NativePlatform.WinUI => "WinUI 3", - NativePlatform.WPF => "WPF", - _ => "Unknown" - }; - - AnsiConsole.MarkupLine($"[green]✓ Detected:[/] {platformName}"); - AnsiConsole.MarkupLine($"[dim]Project: {projectInfo.ProjectName}[/]"); - AnsiConsole.MarkupLine($"[dim]Namespace: {projectInfo.RootNamespace}[/]"); - }); - } - catch + var projectInfo = ProjectDetector.DetectProject(); + var platformName = projectInfo.Platform switch { - projectInfo = ProjectDetector.DetectProject(); - AnsiConsole.MarkupLine($"[green]✓ Detected:[/] {projectInfo.Platform}"); - AnsiConsole.MarkupLine($"[dim]Project: {projectInfo.ProjectName}[/]"); - AnsiConsole.MarkupLine($"[dim]Namespace: {projectInfo.RootNamespace}[/]"); - } + NativePlatform.MAUI => ".NET MAUI", + NativePlatform.Avalonia => "Avalonia UI", + NativePlatform.WinUI => "WinUI 3", + NativePlatform.WPF => "WPF", + _ => "Unknown" + }; + AnsiConsole.MarkupLine($"[green]✓ Detected:[/] {platformName}"); + AnsiConsole.MarkupLine($"[dim]Project: {Markup.Escape(projectInfo.ProjectName)}[/]"); + AnsiConsole.MarkupLine($"[dim]Namespace: {Markup.Escape(projectInfo.RootNamespace)}[/]"); if (projectInfo.Platform == NativePlatform.Unknown && !nonInteractive) { @@ -77,11 +55,10 @@ await AnsiConsole.Status() AnsiConsole.MarkupLine("[yellow]Platform not detected, defaulting to MAUI[/]"); } - await AnsiConsole.Status() - .StartAsync("Setting up ShellUI Native...", async ctx => + await LogoLoader.RunAsync("Setting up ShellUI Native...", async status => { // Create Components/UI folder - ctx.Status("Creating component folders..."); + status("Creating component folders..."); var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "Components", "UI"); var variantsPath = Path.Combine(componentsPath, "Variants"); Directory.CreateDirectory(componentsPath); @@ -89,11 +66,11 @@ await AnsiConsole.Status() AnsiConsole.MarkupLine($"[green]✓ Created:[/] Components/UI/"); // Install Shell utilities - ctx.Status("Installing Shell utilities..."); + status("Installing Shell utilities..."); await InstallShellUtilityAsync(projectInfo, componentsPath); // Create configuration file - ctx.Status("Creating configuration..."); + status("Creating configuration..."); var config = new ShellUINativeConfig { Style = style, @@ -117,7 +94,7 @@ await AnsiConsole.Status() // Create theme resources (MAUI only for now) if (projectInfo.Platform == NativePlatform.MAUI) { - ctx.Status("Creating theme resources..."); + status("Creating theme resources..."); await CreateThemeResourcesAsync(); } }); @@ -142,7 +119,7 @@ private static async Task InstallShellUtilityAsync(ProjectInfo projectInfo, stri if (content == null) return; content = content.Replace("YourProjectNamespace", projectInfo.RootNamespace); - + var filePath = Path.Combine(componentsPath, "Shell.cs"); await File.WriteAllTextAsync(filePath, content); AnsiConsole.MarkupLine($"[green]✓ Installed:[/] Shell.cs"); diff --git a/src/ShellUI.Native.CLI/Services/Loaders.cs b/src/ShellUI.Native.CLI/Services/Loaders.cs new file mode 100644 index 0000000..ea58e8e --- /dev/null +++ b/src/ShellUI.Native.CLI/Services/Loaders.cs @@ -0,0 +1,117 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace ShellUI.Native.CLI.Services; + +public static class Loaders +{ + public static Status SnakeStatus() => + AnsiConsole.Status().Spinner(SnakeSpinner.Instance).SpinnerStyle(Style.Parse("green")); +} + +// ShellUI's "snake" loader as a two-character Braille spinner; Spectre falls back to ASCII without Unicode. +public sealed class SnakeSpinner : Spinner +{ + private static readonly int[] Path = { 0, 1, 2, 5, 8, 7, 6, 3 }; + private static readonly int[,] Bits = { { 0x01, 0x08 }, { 0x02, 0x10 }, { 0x04, 0x20 } }; + + public static readonly SnakeSpinner Instance = new(); + + private SnakeSpinner() + { + Frames = Enumerable.Range(0, Path.Length) + .Select(step => Braille(i => IsLit(Array.IndexOf(Path, i), step))) + .ToList(); + } + + public override TimeSpan Interval => TimeSpan.FromMilliseconds(110); + public override bool IsUnicode => true; + public override IReadOnlyList Frames { get; } + + private static bool IsLit(int position, int step) => + position >= 0 && Enumerable.Range(0, 3).Any(k => (step - k + Path.Length) % Path.Length == position); + + internal static string Braille(Func lit) + { + var chars = new int[2]; + for (var i = 0; i < 9; i++) + if (lit(i)) chars[i % 3 / 2] |= Bits[i / 3, i % 3 % 2]; + return string.Concat(chars.Select(c => (char)(0x2800 + c))); + } +} + +// The ShellUI Native mark (two screens) drawn beside the current step; the snake spinner is used when there is no terminal. +public static class LogoLoader +{ + internal static readonly string[] Pattern = { "XXX..", "X.X..", "XXXXX", "..X.X", "..XXX" }; + private const int Cycle = 30; + + public static async Task RunAsync(string initialStatus, Func, Task> work) + { + var caps = AnsiConsole.Profile.Capabilities; + if (!caps.Interactive || !caps.Unicode || Console.IsOutputRedirected) + { + await Loaders.SnakeStatus().StartAsync(initialStatus, ctx => work(s => ctx.Status(s))); + return; + } + + var status = initialStatus; + await AnsiConsole.Live(Render(0, status)) + .AutoClear(true) + .StartAsync(async ctx => + { + var task = work(s => status = s); + for (var frame = 1; !task.IsCompleted; frame++) + { + ctx.UpdateTarget(Render(frame, status)); + await Task.WhenAny(task, Task.Delay(70)); + } + await task; + }); + } + + // Lit dots switch on along the diagonal, hold, then switch off the same way. + internal static string[] Frame(int frame) + { + var t = frame % Cycle; + return Enumerable.Range(0, 5).Select(r => string.Concat(Enumerable.Range(0, 5).Select(c => + { + var diagonal = r + c; + var on = Pattern[r][c] == 'X' && (t < Cycle / 2 ? diagonal <= t : diagonal > t - Cycle / 2); + return on ? 'X' : '.'; + }))).ToArray(); + } + + private static IRenderable Render(int frame, string status) + { + var grid = new Grid().AddColumn().AddColumn(); + grid.AddRow(new Markup(Dots(Frame(frame))), new Markup($"\n\n {Markup.Escape(status)}")); + return grid; + } + + private static string Dots(IEnumerable rows) => + string.Join("\n", rows.Select(row => string.Join(" ", row.Select(ch => ch == 'X' ? "[white]●[/]" : "[grey19]●[/]")))); + + // The static mark with the name, version and a subtitle beside it (used by init and list). + public static void WriteHeader(string subtitle) + { + var version = typeof(LogoLoader).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion.Split('+')[0] ?? ""; + + if (!AnsiConsole.Profile.Capabilities.Unicode) + { + AnsiConsole.MarkupLine($"[bold]ShellUI Native[/] [dim]{Markup.Escape(version)}[/] {Markup.Escape(subtitle)}\n"); + return; + } + + var grid = new Grid().AddColumn().AddColumn(); + grid.AddRow( + new Markup(Dots(Pattern)), + new Markup($"\n [bold]ShellUI Native[/] [dim]{Markup.Escape(version)}[/]\n [dim]{Markup.Escape(subtitle)}[/]")); + AnsiConsole.WriteLine(); + AnsiConsole.Write(grid); + AnsiConsole.WriteLine(); + } +} diff --git a/src/ShellUI.Native.CLI/Services/ProjectDetector.cs b/src/ShellUI.Native.CLI/Services/ProjectDetector.cs index c8f246a..38339df 100644 --- a/src/ShellUI.Native.CLI/Services/ProjectDetector.cs +++ b/src/ShellUI.Native.CLI/Services/ProjectDetector.cs @@ -9,7 +9,7 @@ public static class ProjectDetector public static ProjectInfo DetectProject() { var csprojFiles = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj"); - + if (csprojFiles.Length == 0) { throw new Exception("No .csproj file found in current directory. Please run this command from your project root."); @@ -17,9 +17,9 @@ public static ProjectInfo DetectProject() var csprojPath = csprojFiles[0]; var projectName = Path.GetFileNameWithoutExtension(csprojPath); - + var doc = XDocument.Load(csprojPath); - + var platform = DetectPlatform(doc, csprojPath); var rootNamespace = DetectRootNamespace(doc, projectName); @@ -35,11 +35,11 @@ public static ProjectInfo DetectProject() internal static NativePlatform DetectPlatform(XDocument doc, string csprojPath) { var sdk = doc.Root?.Attribute("Sdk")?.Value ?? ""; - + // Check for MAUI workload if (sdk.Contains("Maui", StringComparison.OrdinalIgnoreCase)) return NativePlatform.MAUI; - + var useMaui = doc.Descendants("UseMaui").FirstOrDefault()?.Value; if (useMaui?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) return NativePlatform.MAUI; @@ -62,7 +62,7 @@ internal static NativePlatform DetectPlatform(XDocument doc, string csprojPath) // Check for WPF if (sdk.Contains("Wpf", StringComparison.OrdinalIgnoreCase)) return NativePlatform.WPF; - + var useWpf = doc.Descendants("UseWPF").FirstOrDefault()?.Value; if (useWpf?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) return NativePlatform.WPF; @@ -70,7 +70,7 @@ internal static NativePlatform DetectPlatform(XDocument doc, string csprojPath) // Try to detect from output type and target framework var targetFramework = doc.Descendants("TargetFramework").FirstOrDefault()?.Value ?? ""; var targetFrameworks = doc.Descendants("TargetFrameworks").FirstOrDefault()?.Value ?? ""; - + // MAUI typically targets multiple platforms if (targetFrameworks.Contains("android") || targetFrameworks.Contains("ios")) return NativePlatform.MAUI; diff --git a/src/ShellUI.Native.Core/Models/ComponentMetadata.cs b/src/ShellUI.Native.Core/Models/ComponentMetadata.cs index f631fa8..498946e 100644 --- a/src/ShellUI.Native.Core/Models/ComponentMetadata.cs +++ b/src/ShellUI.Native.Core/Models/ComponentMetadata.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Reflection; namespace ShellUI.Native.Core.Models; @@ -28,40 +28,11 @@ public class ComponentMetadata // Searchable tags for the component public List Tags { get; set; } = new(); + // Directory.Build.props stamps the package version into the assembly; an installed tool has no props file to read. private static string GetCurrentVersion() { - // Try to read version from Directory.Build.props - try - { - var currentDir = AppDomain.CurrentDomain.BaseDirectory; - var dir = new DirectoryInfo(currentDir); - - while (dir != null) - { - var propsFile = Path.Combine(dir.FullName, "Directory.Build.props"); - if (File.Exists(propsFile)) - { - var content = File.ReadAllText(propsFile); - var match = Regex.Match(content, @"([^<]+)"); - if (match.Success) - { - var version = match.Groups[1].Value.Trim(); - var suffixMatch = Regex.Match(content, @"([^<]*)"); - if (suffixMatch.Success && !string.IsNullOrEmpty(suffixMatch.Groups[1].Value.Trim())) - { - version += "-" + suffixMatch.Groups[1].Value.Trim(); - } - return version; - } - } - dir = dir.Parent; - } - } - catch - { - // Ignore errors, return fallback - } - - return "0.1.0"; + var informational = typeof(ComponentMetadata).Assembly + .GetCustomAttribute()?.InformationalVersion; + return string.IsNullOrEmpty(informational) ? "0.0.0" : informational.Split('+')[0]; } } diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index 1c3e2c2..84960e3 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -172,4 +172,17 @@ public static bool Exists(string componentName) { return _templates.ContainsKey(componentName.ToLower()); } + + /* The family a compositional part belongs to (`dialog-trigger` → `dialog`), or null. + Parts find their parent type at runtime and parents depend on their parts, so a part + only builds when its family is installed. */ + public static string? GetFamily(string componentName) + { + var name = componentName.ToLower(); + return _templates + .Where(t => name.StartsWith(t.Key + "-") && t.Value.Meta.Dependencies.Contains(name)) + .Select(t => t.Key) + .OrderByDescending(k => k.Length) + .FirstOrDefault(); + } } diff --git a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs index 9423174..2da6951 100644 --- a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs +++ b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs @@ -125,4 +125,52 @@ public void GetSupportedPlatforms_returns_empty_for_unknown_component() var supported = ComponentRegistry.GetSupportedPlatforms("nonexistent-xyz"); Assert.Empty(supported); } + + [Theory] + [InlineData("dialog-trigger", "dialog")] + [InlineData("dialog-close", "dialog")] + [InlineData("drawer-content", "drawer")] + [InlineData("sheet-trigger", "sheet")] + [InlineData("dropdown-item", "dropdown")] + [InlineData("popover-trigger", "popover")] + [InlineData("hover-card-trigger", "hover-card")] + [InlineData("radio-group-item", "radio-group")] + [InlineData("collapsible-trigger", "collapsible")] + [InlineData("accordion-item", "accordion")] + [InlineData("tabs-list", "tabs")] + [InlineData("card-header", "card")] + public void GetFamily_maps_a_part_to_its_parent(string part, string family) + { + Assert.Equal(family, ComponentRegistry.GetFamily(part)); + } + + [Theory] + [InlineData("dialog")] + [InlineData("alert-dialog")] + [InlineData("toggle-group")] + [InlineData("tag-input")] + [InlineData("date-picker")] + [InlineData("hover-card")] + [InlineData("nonexistent-xyz")] + public void GetFamily_is_null_for_standalone_components(string name) + { + Assert.Null(ComponentRegistry.GetFamily(name)); + } + + // The installed tool has no Directory.Build.props, so the version must reach it through the assembly. + [Fact] + public void Component_version_is_the_package_version_from_Directory_Build_props() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Directory.Build.props"))) + dir = dir.Parent; + Assert.NotNull(dir); + + var props = File.ReadAllText(Path.Combine(dir!.FullName, "Directory.Build.props")); + var version = System.Text.RegularExpressions.Regex.Match(props, "([^<]+)<").Groups[1].Value; + var suffix = System.Text.RegularExpressions.Regex.Match(props, "([^<]*)<").Groups[1].Value; + var expected = suffix.Length == 0 ? version : $"{version}-{suffix}"; + + Assert.All(ComponentRegistry.Components.Values, m => Assert.Equal(expected, m.Version)); + } } diff --git a/tests/ShellUI.Native.Tests/LoadersTests.cs b/tests/ShellUI.Native.Tests/LoadersTests.cs new file mode 100644 index 0000000..eaaa886 --- /dev/null +++ b/tests/ShellUI.Native.Tests/LoadersTests.cs @@ -0,0 +1,43 @@ +using ShellUI.Native.CLI.Services; + +namespace ShellUI.Native.Tests; + +public class LoadersTests +{ + [Fact] + public void SnakeSpinner_walks_the_grid_edge_with_a_three_dot_trail() + { + var frames = SnakeSpinner.Instance.Frames; + + Assert.Equal(8, frames.Count); + Assert.Equal(8, frames.Distinct().Count()); + foreach (var frame in frames) + { + Assert.Equal(2, frame.Length); + Assert.All(frame, ch => Assert.InRange(ch, '⠀', '⣿')); + Assert.Equal(3, frame.Sum(ch => System.Numerics.BitOperations.PopCount((uint)(ch - 0x2800)))); + } + + // The centre cell (row 1, column 1 → first character, dot 0x10) is never part of the snake path. + Assert.DoesNotContain(frames, f => ((f[0] - 0x2800) & 0x10) != 0); + } + + [Fact] + public void Braille_maps_grid_cells_to_dots() + { + Assert.Equal("⠉⠁", SnakeSpinner.Braille(i => i < 3)); + Assert.Equal("⠇⠀", SnakeSpinner.Braille(i => i % 3 == 0)); + } + + // Same rows as the ShellUI Native mark in md-files BRAND_MARKS.md (two screens). + [Fact] + public void LogoLoader_builds_the_two_screens_mark_along_the_diagonal_then_clears_it() + { + Assert.Equal(new[] { "XXX..", "X.X..", "XXXXX", "..X.X", "..XXX" }, LogoLoader.Pattern); + Assert.Equal(new[] { "X....", ".....", ".....", ".....", "....." }, LogoLoader.Frame(0)); + Assert.Equal(LogoLoader.Pattern, LogoLoader.Frame(8)); + Assert.Equal(LogoLoader.Frame(8), LogoLoader.Frame(14)); + Assert.Equal(new[] { ".....", ".....", ".....", ".....", "....." }, LogoLoader.Frame(29)); + Assert.Equal(LogoLoader.Frame(0), LogoLoader.Frame(30)); + } +}