Compare commits
5 Commits
212-test-a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d552c7ae8 | |||
| edf7646a18 | |||
| 445464afdc | |||
| 3bdd27f747 | |||
| ea9ef9cb38 |
@ -3,6 +3,9 @@ apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
apps/website/playwright-report/
|
||||
apps/website/test-results/
|
||||
apps/website/blob-report/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
|
||||
11
.github/agents/copilot-instructions.md
vendored
11
.github/agents/copilot-instructions.md
vendored
@ -7,6 +7,7 @@ ## Relocation override
|
||||
- Human-facing commands should use `cd apps/platform && ...`.
|
||||
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
|
||||
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
|
||||
|
||||
@ -202,6 +203,11 @@ ## Active Technologies
|
||||
- SQLite `:memory:` for lane execution, filesystem artifacts under `apps/platform/storage/logs/test-lanes`, staged CI bundles under `.gitea-artifacts/<workflow-profile>`, bounded derived trend/history artifacts adjacent to current lane artifacts, and no new product database persistence (211-runtime-trend-recalibration)
|
||||
- Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary (212-test-authoring-guardrails)
|
||||
- Repository-owned markdown and contract artifacts under `.specify/`, `specs/212-test-authoring-guardrails/`, and root documentation files; no product database persistence (212-test-authoring-guardrails)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext` (199-global-context-shell-contract)
|
||||
- PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned (199-global-context-shell-contract)
|
||||
- Markdown governance artifacts in a PHP 8.4.15 / Laravel 12 / Filament v5 / Livewire v4 repository + `.specify/memory/constitution.md`, `docs/ui/operator-ux-surface-standards.md`, adjacent Specs 196 through 199, existing UI rule IDs `UI-SURF-001`, `ACTSURF-001`, `UI-HARD-001`, `UI-EX-001`, `UI-FIL-001`, `DECIDE-001`, and `UX-001` (200-filament-surface-rules)
|
||||
- Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests (213-website-foundation-v0)
|
||||
- Static filesystem content, styles, and assets under `apps/website/src` and `apps/website/public`; no database (213-website-foundation-v0)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -236,8 +242,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests
|
||||
- 200-filament-surface-rules: Added Markdown governance artifacts in a PHP 8.4.15 / Laravel 12 / Filament v5 / Livewire v4 repository + `.specify/memory/constitution.md`, `docs/ui/operator-ux-surface-standards.md`, adjacent Specs 196 through 199, existing UI rule IDs `UI-SURF-001`, `ACTSURF-001`, `UI-HARD-001`, `UI-EX-001`, `UI-FIL-001`, `DECIDE-001`, and `UX-001`
|
||||
- 199-global-context-shell-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext`
|
||||
- 212-test-authoring-guardrails: Added Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary
|
||||
- 211-runtime-trend-recalibration: Added PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
|
||||
- 210-ci-matrix-budget-enforcement: Added PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@ -293,6 +293,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Workspace Commands
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,6 +19,9 @@
|
||||
/apps/website/node_modules
|
||||
/.pnpm-store
|
||||
/apps/website/.astro
|
||||
/apps/website/playwright-report
|
||||
/apps/website/test-results
|
||||
/apps/website/blob-report
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
@ -7,6 +7,9 @@ apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
apps/website/playwright-report/
|
||||
apps/website/test-results/
|
||||
apps/website/blob-report/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
*.log
|
||||
|
||||
@ -1,33 +1,19 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.4.0 -> 2.5.0
|
||||
- Version change: 2.5.0 -> 2.6.0
|
||||
- Modified principles:
|
||||
- Test Suite Governance Must Live In The Delivery Workflow
|
||||
(TEST-GOV-001): expanded into explicit test-impact disclosure,
|
||||
lane discipline, minimal-fixture defaults, heavy-family visibility,
|
||||
expensive-default bans, runtime-budget stewardship, review-stop
|
||||
rules, and escalation triggers
|
||||
- Governance review expectations: expanded to require test-purpose
|
||||
classification, explicit runtime-cost review, and visible review
|
||||
routine coverage in delivery artifacts
|
||||
- UI surface taxonomy and review expectations: expanded with native
|
||||
vs custom classification, shared-detail host ownership, named
|
||||
anti-patterns, and shell/page/detail state ownership review
|
||||
- Filament Native First / No Ad-hoc Styling (UI-FIL-001): expanded
|
||||
into explicit native-by-default, fake-native, shared-family, and
|
||||
exception-boundary language
|
||||
- Added sections: None
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/plan-template.md (lane-discipline and
|
||||
escalation-planning checks expanded)
|
||||
- ✅ .specify/templates/spec-template.md (test-purpose,
|
||||
lane-discipline, heavy-family, and escalation prompts expanded)
|
||||
- ✅ .specify/templates/tasks-template.md (task obligations expanded for
|
||||
classification, cheap defaults, review-stop rules, and runtime
|
||||
stewardship)
|
||||
- ✅ .specify/templates/checklist-template.md (review checklist guidance
|
||||
expanded for lane fit, heavy risk, and escalation)
|
||||
- ✅ .specify/README.md (SpecKit workflow expectations expanded for
|
||||
visible test-governance coverage)
|
||||
- ✅ README.md (developer workflow guidance expanded for lane
|
||||
discipline and runtime stewardship)
|
||||
- None in this docs-only constitution slice; enforcement remains
|
||||
deferred to Spec 201
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs: None
|
||||
@ -579,6 +565,24 @@ ##### Detail-first Operational Surface
|
||||
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||
- Row click and explicit View/Inspect: not applicable.
|
||||
|
||||
##### Native vs custom and shared-family classification
|
||||
- Every operator-facing surface MUST also classify whether it is a
|
||||
`Native Surface`, a `Custom Surface`, or a `Shared Detail Micro-UI`
|
||||
embedded inside a `Host`.
|
||||
- `Native Surface` means the primary interaction contract is expressed
|
||||
through Filament-native components or approved shared primitives.
|
||||
- `Custom Surface` means the operator need is materially richer than
|
||||
standard CRUD, overview, or report semantics and the deviation is
|
||||
justified through UI-EX-001.
|
||||
- `Shared Detail Micro-UI` means a repeated embedded review, evidence,
|
||||
or detail surface that appears in more than one host and must read as
|
||||
the same family wherever it appears.
|
||||
- `Host` means the page, resource, workbench, or detail surface that
|
||||
embeds a shared detail micro-UI and owns routing, authorization, the
|
||||
outer inspect/open model, and host-only actions.
|
||||
- A `Fake-Native Surface` is never an allowed classification. It is a
|
||||
violation class defined by UI-HARD-001 and UI-FIL-001.
|
||||
|
||||
#### Action Surface Discipline (ACTSURF-001)
|
||||
|
||||
Goal: actions across all surfaces MUST make the next sensible operator
|
||||
@ -649,6 +653,22 @@ ##### Utility / System surfaces
|
||||
- System or recovery status does not justify casual placement of
|
||||
destructive or governance-changing actions.
|
||||
|
||||
##### Shared detail families and one primary interaction model
|
||||
|
||||
- A shared detail micro-UI MUST define one family-level core
|
||||
interaction model before a second host extends it.
|
||||
- Hosts MAY vary framing, assist entry, surrounding navigation, or
|
||||
optional diagnostics only when the shared core remains recognizable
|
||||
and the variation is explicit.
|
||||
- The host owns page-level navigation, authorization, surrounding
|
||||
mutations, and dangerous actions. The shared family owns only the
|
||||
repeated read/inspect/view semantics that are intentionally common.
|
||||
- One user concern MUST NOT be split across two peer interaction
|
||||
models on the same page.
|
||||
- `Parallel Inspect Worlds` means the same concern is driven by two
|
||||
competing inspect, selected-record, or view-state owners. It is
|
||||
forbidden.
|
||||
|
||||
##### Action grouping and order
|
||||
|
||||
- Actions MUST be ordered by meaning, frequency, and risk.
|
||||
@ -718,7 +738,12 @@ ##### Review gate
|
||||
3. Is navigation cleanly separated from mutation?
|
||||
4. Are rare or risky actions removed from the primary plane?
|
||||
5. Is the hierarchy scanable in a few seconds?
|
||||
6. Is this a real special type or just an unordered exception?
|
||||
6. If this is a repeated detail family, what is shared core vs
|
||||
host-owned variation?
|
||||
7. Does one concern still have exactly one primary interaction model?
|
||||
8. Which layer owns the relevant truth: shell, page, or detail?
|
||||
9. Is any exception real, bounded, and named, or is it a hidden
|
||||
exception?
|
||||
|
||||
If those answers are not clear, the surface is non-conformant.
|
||||
|
||||
@ -737,6 +762,11 @@ ##### Primary inspect model
|
||||
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
||||
- CRUD / List-first and Read-only Registry / Report surfaces MUST provide an obvious one-click open path.
|
||||
- Queue / Review and History / Audit surfaces MUST use explicit Inspect rather than row-click navigation.
|
||||
- Inline detail, summary, or sidebar inspect MAY exist only as
|
||||
subordinate presentations of the same selected-record truth, not as a
|
||||
second inspect contract.
|
||||
- `Parallel Inspect Worlds` are forbidden even when each local variant
|
||||
looks individually reasonable.
|
||||
|
||||
##### Row-click semantics
|
||||
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
|
||||
@ -755,6 +785,29 @@ ##### Action hierarchy
|
||||
- All other secondary actions MUST move to overflow.
|
||||
- Long-running workflow launches such as sync, compare, verify, generate, consent, setup, or retry SHOULD live in list headers or detail headers rather than in every row.
|
||||
|
||||
##### Native-by-default and fake-native drift
|
||||
- Standard form, filter, table, action, tab, badge, link, and overview
|
||||
work is `Native Surface` work by default when Filament or an existing
|
||||
shared primitive can express it.
|
||||
- A `Fake-Native Surface` is any surface that visually lives inside
|
||||
Filament but keeps a second HTML, GET, query, or Blade-request
|
||||
interaction contract for its primary behavior.
|
||||
- Simple report or overview pages with ordinary columns, filters, empty
|
||||
states, and navigation default to native table semantics.
|
||||
- `Filament Costume` means locally assembled markup imitates native
|
||||
Filament controls, badges, or actions even though native or shared
|
||||
primitives fit. It is forbidden.
|
||||
- `Blade Request UI` means the primary body-state contract depends on
|
||||
`request()`, GET forms, or manual query parsing inside an active
|
||||
Filament surface. It is forbidden unless a documented exception limits
|
||||
request input to initialization-only behavior.
|
||||
- `Hand-Rolled Simple Overview` means a simple report or overview is
|
||||
rebuilt as bespoke markup where native table/list/report semantics
|
||||
fit. It is forbidden.
|
||||
- `Hidden Exception` means a surface behaves like a custom or special
|
||||
case without naming an exception type and reason block. It is
|
||||
forbidden.
|
||||
|
||||
##### Destructive actions
|
||||
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces.
|
||||
- Destructive actions MUST live in overflow or the detail header.
|
||||
@ -802,6 +855,16 @@ ##### Row density and scanability
|
||||
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose.
|
||||
- Next-step prose belongs in detail, inspect, or queue surfaces, not in ordinary CRUD rows.
|
||||
|
||||
##### Shared-family and state-layer violations
|
||||
- `Host Drift` occurs when a host silently redefines a shared detail
|
||||
micro-UI's core zones, diagnostics contract, or primary view/inspect
|
||||
model. Host Drift is forbidden.
|
||||
- `State Layer Collapse` occurs when shell, page, or detail layers each
|
||||
claim the same active truth or restoration responsibility. It is
|
||||
forbidden.
|
||||
- A lower layer MAY format or reveal a higher-layer truth, but it MUST
|
||||
NOT quietly become the higher layer's authority.
|
||||
|
||||
##### Custom abstractions
|
||||
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior.
|
||||
- Contract systems MUST NOT force placeholder UI.
|
||||
@ -812,6 +875,11 @@ #### Exception Model (UI-EX-001)
|
||||
|
||||
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
||||
|
||||
- A `Legitimate Exception` is a named, bounded deviation that states the
|
||||
product reason, the smallest custom behavior required, what remains
|
||||
standardized, which layer owns the relevant state, and what proof or
|
||||
review evidence keeps it from turning into a general permission slip.
|
||||
|
||||
##### Queue Decision Exception
|
||||
- Allowed when per-item decision-making is the real queue work.
|
||||
- Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
|
||||
@ -832,6 +900,38 @@ ##### Cross-panel Canonical Route Exception
|
||||
- Allowed when only one canonical surface makes sense.
|
||||
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful.
|
||||
|
||||
##### Legitimate Custom Surface Exception
|
||||
- Allowed when the operator need is materially richer than ordinary
|
||||
CRUD, overview, or simple report semantics, such as richer
|
||||
visualization, high-value diagnostic or review work, multi-zone shared
|
||||
detail micro-UI, shell-context-specific UI, or domain presentation
|
||||
that native primitives do not express cleanly.
|
||||
- Guardrails: the spec MUST state the product reason, the smallest
|
||||
custom behavior required, which native/shared primitives still apply,
|
||||
which layer owns the relevant state, and what remains standardized.
|
||||
|
||||
##### Nativity Exception
|
||||
- Allowed only when Filament-native or shared primitives cannot express
|
||||
the required semantics cleanly.
|
||||
- Guardrails: the exception MUST name the missing semantic, reject
|
||||
`Filament Costume`, `Blade Request UI`, and `Hand-Rolled Simple
|
||||
Overview` shortcuts, keep native/shared surrounding controls where
|
||||
they still fit, and MUST NOT invent a local status language.
|
||||
|
||||
##### Shared Detail Host Variation Exception
|
||||
- Allowed when a known shared detail micro-UI needs bounded host
|
||||
framing, assist entry, or optional-zone variation.
|
||||
- Guardrails: the host MUST NOT redefine the family core zones,
|
||||
next-step contract, diagnostics contract, or primary view/inspect
|
||||
model. Differences stay visibly host-scoped.
|
||||
|
||||
##### State-Layer Special-case Exception
|
||||
- Allowed when a page legitimately needs explicit requested, active,
|
||||
draft, inspect, or restorable roles beyond the simple default.
|
||||
- Guardrails: the owner layer MUST be explicit, the restorable subset
|
||||
MUST be explicit, any query role MUST be documented, and no lower
|
||||
layer may silently take over shell or page truth.
|
||||
|
||||
#### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
|
||||
For every new or modified Filament Resource, RelationManager, or Page:
|
||||
@ -842,6 +942,10 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
||||
- CRUD / List-first, Config-lite, and Read-only Registry surfaces MUST NOT render a redundant View action when the same destination is already available through row click or identifier click.
|
||||
- Queue / Review and History / Audit surfaces MAY use a lone explicit Inspect action because context-preserving inspect is the primary interaction.
|
||||
- Simple report or overview pages with ordinary columns, filters,
|
||||
empty-state behavior, and navigation MUST be implemented as native
|
||||
table surfaces unless UI-EX-001 documents why a richer custom surface
|
||||
is required.
|
||||
- View/Detail MUST define header actions and MUST keep destructive actions grouped and confirmed.
|
||||
- View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
|
||||
- Create/Edit MUST provide consistent Save and Cancel UX.
|
||||
@ -850,6 +954,9 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
||||
- Queue / Review rows MAY expose inline decision actions only when allowed by UI-EX-001.
|
||||
- Everything else MUST move to `ActionGroup::make()` or the detail header.
|
||||
- Repeated embedded detail/evidence families MUST declare one shared
|
||||
core contract. Host-specific navigation, mutations, and destructive
|
||||
actions stay outside that shared core.
|
||||
- Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
|
||||
- Empty `ActionGroup` and `BulkActionGroup` are forbidden.
|
||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
|
||||
@ -864,6 +971,12 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||
- Every changed operator-facing surface MUST declare its broad
|
||||
action-surface class and the one most likely next operator action.
|
||||
- Every changed operator-facing surface MUST also declare whether it is
|
||||
a `Native Surface`, `Custom Surface`, or `Shared Detail Micro-UI`, and
|
||||
MUST name any exception type it relies on.
|
||||
- If a surface uses shell, page, or detail state beyond simple static
|
||||
rendering, the governing spec MUST name which layer owns the relevant
|
||||
requested, active, draft, inspect, and restorable state.
|
||||
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||
|
||||
@ -899,6 +1012,26 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
||||
- Critical operational truth that informs list decisions MUST be default-visible.
|
||||
|
||||
State ownership
|
||||
- `Global Context State` is shell-owned workspace, tenant, or tenantless
|
||||
truth. Context bars and shell partials may display it, but they MUST
|
||||
NOT invent second precedence or fallback logic.
|
||||
- `Page State` is page-owned filter, tab, mode, or selected-record truth
|
||||
that changes the current page result or workflow.
|
||||
- `Detail State` is embedded viewer or shared-family state inside one
|
||||
detail surface and remains subordinate to shell and page truth unless
|
||||
an approved exception says otherwise.
|
||||
- `Requested State` is route, query, or upstream input before validation.
|
||||
- `Active State` is the currently governing validated state.
|
||||
- `Draft State` is local pending state that is intentionally separate
|
||||
from the currently applied result state.
|
||||
- `Inspect State` is the selected-record or selected-detail focus that
|
||||
drives inline or same-page inspect.
|
||||
- `Restorable State` is the subset intentionally recreated by refresh,
|
||||
back, bookmark, or shared link.
|
||||
- `State Layer Collapse` is forbidden. Shell, page, and detail layers
|
||||
MUST NOT silently overwrite one another's authority.
|
||||
|
||||
Enforcement
|
||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||
@ -920,6 +1053,16 @@ ##### Core rule
|
||||
contextual references do not belong in the header; they belong directly
|
||||
at the affected field, status indicator, or relation.
|
||||
|
||||
##### Shared-family host discipline
|
||||
|
||||
- If a record or detail page embeds a shared detail micro-UI, the
|
||||
header remains host-owned.
|
||||
- Family-level view switches, tabs, diagnostics reveals, and inner
|
||||
assist controls belong inside the family, not as copied header
|
||||
buttons.
|
||||
- Host-specific navigation or mutations MAY appear in the header only
|
||||
when they are truly host-critical and do not redefine the family core.
|
||||
|
||||
##### Maximum one primary visible header action
|
||||
|
||||
- Each record/detail page MUST expose at most one clearly prioritized
|
||||
@ -1022,6 +1165,9 @@ ##### Reviewer heuristics
|
||||
- Pure navigation buttons in the header.
|
||||
- Danger actions beside normal actions without clear separation.
|
||||
- Rarely used administrative actions as visible standard buttons.
|
||||
- Shared-family view switches or host-only forks are exported into the
|
||||
main header instead of staying inside the family or contextual
|
||||
placement.
|
||||
- The header resembles an action stockpile instead of a focused
|
||||
workflow entry point.
|
||||
|
||||
@ -1120,8 +1266,12 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
actions are ordered, canonical collection route, canonical detail
|
||||
route, scope signals and their exact meaning, canonical noun,
|
||||
critical truth visible by default, workflow-vs-storage IA
|
||||
justification, attention-load reduction, and whether an exception
|
||||
type is used.
|
||||
justification, attention-load reduction, whether the surface is
|
||||
native, custom, or a shared detail family, what shared core vs host
|
||||
variation exists if relevant, which layer owns the relevant shell,
|
||||
page, and detail truth, which requested/active/draft/inspect/
|
||||
restorable roles exist, whether any fake-native or host-drift risk is
|
||||
present, and whether an exception type is used.
|
||||
- Missing any of those answers makes the spec incomplete.
|
||||
|
||||
PR review requirements
|
||||
@ -1136,8 +1286,10 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
promoted into primary navigation without justification, one case
|
||||
fragmented across multiple equal-rank pages, new automation that adds
|
||||
attention surfaces without reducing operator work, noisy default
|
||||
surfaces with no action/watch/reference hierarchy, or undocumented
|
||||
exceptions without dedicated tests.
|
||||
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
||||
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||
undocumented exceptions without dedicated tests.
|
||||
|
||||
Guard tests
|
||||
- Repository guards SHOULD validate: declared surface type, declared
|
||||
@ -1146,8 +1298,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
presence of explicit Inspect on Queue / Review and History / Audit
|
||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||
correct placement of destructive actions, truthful scope signals,
|
||||
stable canonical nouns across shells, and dedicated tests for every
|
||||
approved exception.
|
||||
stable canonical nouns across shells, absence of fake-native primary
|
||||
controls where metadata says the surface is native, bounded shared
|
||||
family contracts where metadata says a family is reused, explicit
|
||||
state ownership where specs or metadata expose it, and dedicated
|
||||
tests for every approved exception.
|
||||
|
||||
#### Immediate Retrofit Priorities
|
||||
|
||||
@ -1200,6 +1355,13 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Destructive actions never sit openly beside inspect on standard lists.
|
||||
- Overflow is standardized per surface class and is never empty.
|
||||
- Bulk exists only when it is genuinely useful.
|
||||
- Standard forms, filters, tables, tabs, badges, links, and simple
|
||||
overviews are native-by-default.
|
||||
- Fake-native surfaces, hidden exceptions, host drift, and state-layer
|
||||
collapse do not ship.
|
||||
- Repeated detail micro-UIs define shared core and bounded host
|
||||
variation before a second host forks them.
|
||||
- Shell, page, and detail truth each have one owner.
|
||||
- Navigation and mutation do not share equal visual weight without
|
||||
explicit hierarchy.
|
||||
- Monitoring and workbench surfaces separate scope/context, selection,
|
||||
@ -1228,10 +1390,14 @@ #### Appendix B - Feature Review Checklist
|
||||
- Broad action-surface class is declared.
|
||||
- Detailed surface type is declared.
|
||||
- The one most likely next operator action is explicit.
|
||||
- The surface is classified correctly as native, custom, or shared
|
||||
family.
|
||||
- Primary inspect/open model is defined.
|
||||
- Row-click rule is decided.
|
||||
- View/Inspect is correctly present or correctly forbidden.
|
||||
- Edit-as-inspect is used only when allowed.
|
||||
- Fake-native shortcuts are absent or explicitly exception-gated.
|
||||
- Shared-family core vs host-owned variation is explicit where relevant.
|
||||
- Navigation and mutation are separated intentionally.
|
||||
- Secondary actions are grouped correctly.
|
||||
- Destructive actions are placed correctly.
|
||||
@ -1242,6 +1408,9 @@ #### Appendix B - Feature Review Checklist
|
||||
- Canonical nouns stay consistent.
|
||||
- Critical truth is visible.
|
||||
- Scanability is preserved.
|
||||
- Shell, page, and detail state owners are explicit.
|
||||
- Requested, active, draft, inspect, and restorable roles are explicit
|
||||
when the surface uses them.
|
||||
- Exceptions are documented and tested.
|
||||
- Header passes the 5-second scan rule (HDR-001).
|
||||
- No pure navigation in the header.
|
||||
@ -1261,6 +1430,11 @@ #### Appendix C - Red Flags for Future PRs
|
||||
attention load.
|
||||
- The surface creates more noise than priority.
|
||||
- Row click and View open the same destination.
|
||||
- A Filament-looking surface keeps its real primary contract in raw HTML,
|
||||
GET, or Blade request state.
|
||||
- A simple overview is rebuilt as bespoke markup without a real product
|
||||
reason.
|
||||
- A special surface exists only by history and lacks a named exception.
|
||||
- A row becomes a control center.
|
||||
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||
- More menus or bulk menus are empty.
|
||||
@ -1274,6 +1448,10 @@ #### Appendix C - Red Flags for Future PRs
|
||||
actions as one flat header rail.
|
||||
- Critical health or operability truth is hidden by default.
|
||||
- A contract claims conformance while the rendered UI behaves differently.
|
||||
- A repeated detail surface quietly changes family core structure from
|
||||
one host to another.
|
||||
- Shell, page, and detail layers each claim the same truth.
|
||||
- One concern has two competing inspect or view-state owners.
|
||||
- Header has multiple equally weighted buttons without clear prioritization.
|
||||
- "Open X" navigation links placed in the header instead of at the related field.
|
||||
- Governance-changing actions sit casually beside the primary action without friction.
|
||||
@ -1294,14 +1472,36 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||
|
||||
Native-by-default classification
|
||||
- `Native Surface` means the primary interaction contract is built from
|
||||
Filament-native components or approved shared primitives.
|
||||
- Standard forms, filters, tables, tabs, badges, links, and simple
|
||||
overviews default to `Native Surface` status.
|
||||
- `Custom Surface` is allowed only through UI-EX-001 when the operator
|
||||
need is richer than standard CRUD, overview, or report semantics.
|
||||
- `Fake-Native Surface` is forbidden: a surface that looks native but
|
||||
keeps a second HTML, GET, query, or Blade-request contract for the
|
||||
same primary interaction.
|
||||
|
||||
Forbidden local replacements
|
||||
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||
- `Filament Costume` is forbidden: locally assembled markup that merely
|
||||
imitates native Filament controls, badges, or actions.
|
||||
- `Blade Request UI` is forbidden: request-driven body state or GET-form
|
||||
control rails as the primary interaction contract inside an active
|
||||
Filament surface.
|
||||
- `Hand-Rolled Simple Overview` is forbidden: bespoke overview/report
|
||||
shells where a native table/list surface fits the job.
|
||||
|
||||
Shared primitive before local override
|
||||
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||
- If the same custom detail, evidence, or review surface appears in
|
||||
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
||||
define shared core vs host variation before another host reassembles
|
||||
it locally.
|
||||
|
||||
Upgrade-safe preference
|
||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||
@ -1313,13 +1513,19 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- native Filament components cannot express the required semantics,
|
||||
- no suitable shared primitive exists,
|
||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||
classes necessary, MUST NOT invent a new page-local status language,
|
||||
and MUST say what remains standardized.
|
||||
- `Hidden Exception` is forbidden. Historical accident or local
|
||||
implementation convenience is not a valid substitute for UI-EX-001.
|
||||
|
||||
Review and enforcement
|
||||
- Every UI review MUST answer:
|
||||
- which native Filament element or shared primitive was used,
|
||||
- why an existing component was insufficient if an exception was taken,
|
||||
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||
- whether the surface is native, custom, or a shared detail family,
|
||||
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||
contract was introduced.
|
||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||
|
||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||
@ -1367,4 +1573,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18
|
||||
**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18
|
||||
|
||||
@ -722,6 +722,7 @@ ## Application Structure & Architecture
|
||||
## Frontend Bundling
|
||||
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
|
||||
@ -560,6 +560,7 @@ ## Application Structure & Architecture
|
||||
## Frontend Bundling
|
||||
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
|
||||
11
README.md
11
README.md
@ -12,14 +12,17 @@ ## Multi-App Topology
|
||||
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
|
||||
- `./scripts/platform-sail`: platform-only compatibility helper for tooling that cannot set `cwd`
|
||||
|
||||
Website-track guardrails for independent evolution live in
|
||||
[`docs/strategy/website-working-contract.md`](docs/strategy/website-working-contract.md).
|
||||
|
||||
## Official Root Commands
|
||||
|
||||
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
|
||||
- Start the platform stack: `corepack pnpm dev:platform`
|
||||
- Start the platform stack and Laravel panel Vite watcher: `corepack pnpm dev:platform`
|
||||
- Start the website dev server: `corepack pnpm dev:website`
|
||||
- Start platform + website together: `corepack pnpm dev`
|
||||
- Start platform Vite + website together: `corepack pnpm dev`
|
||||
- Build the website: `corepack pnpm build:website`
|
||||
- Build platform frontend assets: `corepack pnpm build:platform`
|
||||
- Build platform frontend assets inside Sail: `corepack pnpm build:platform`
|
||||
|
||||
## App-Local Commands
|
||||
|
||||
@ -29,7 +32,7 @@ ### Platform
|
||||
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||
- Run frontend watch/build inside Sail: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- Run frontend watch/build inside Sail: `corepack pnpm dev:platform`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
|
||||
### Website
|
||||
|
||||
@ -127,12 +127,14 @@ public function selectWorkspace(int $workspaceId): void
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
$redirectTarget = $resolver->resolve(
|
||||
$workspace,
|
||||
$user,
|
||||
WorkspaceIntendedUrl::consume(request()),
|
||||
);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
}
|
||||
@ -170,12 +172,14 @@ public function createWorkspace(array $data): void
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
||||
$redirectTarget = $resolver->resolve(
|
||||
$workspace,
|
||||
$user,
|
||||
WorkspaceIntendedUrl::consume(request()),
|
||||
);
|
||||
|
||||
$this->redirect($redirectTarget);
|
||||
}
|
||||
|
||||
@ -69,15 +69,13 @@ public function __invoke(Request $request): RedirectResponse
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
|
||||
$intendedUrl = WorkspaceIntendedUrl::consume($request);
|
||||
|
||||
if ($intendedUrl !== null) {
|
||||
return redirect()->to($intendedUrl);
|
||||
}
|
||||
|
||||
/** @var WorkspaceRedirectResolver $resolver */
|
||||
$resolver = app(WorkspaceRedirectResolver::class);
|
||||
|
||||
return redirect()->to($resolver->resolve($workspace, $user));
|
||||
return redirect()->to($resolver->resolve(
|
||||
$workspace,
|
||||
$user,
|
||||
WorkspaceIntendedUrl::consume($request),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,18 +8,14 @@
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Navigation\NavigationBuilder;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Illuminate\Http\Request;
|
||||
@ -33,6 +29,7 @@ class EnsureFilamentTenantSelected
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
$resolvedContext = app(OperateHubShell::class)->resolvedContext($request);
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
@ -85,75 +82,27 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = null;
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
} elseif (filled($request->query('tenant'))) {
|
||||
$tenantParameter = $request->query('tenant');
|
||||
}
|
||||
|
||||
if (
|
||||
$tenantParameter === null
|
||||
&& ! $this->hasCanonicalTenantSelection($request)
|
||||
! $resolvedContext->hasTenant()
|
||||
&& $this->adminPathRequiresTenantSelection($path)
|
||||
) {
|
||||
return redirect()->route('filament.admin.pages.choose-tenant');
|
||||
}
|
||||
|
||||
if ($tenantParameter !== null) {
|
||||
$user = $request->user();
|
||||
if ($resolvedContext->pageCategory === TenantPageCategory::TenantBound && ! $resolvedContext->hasTenant()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $user instanceof HasTenants) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $tenantParameter instanceof Tenant
|
||||
? $tenantParameter
|
||||
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($workspaceId === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $this->routeTenantIsAuthorized($tenant, $user, $workspaceId, $path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($this->isWorkspaceScopedPageWithTenant($path)) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
|
||||
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
if (
|
||||
$resolvedContext->hasTenant()
|
||||
&& (
|
||||
$panel?->getId() === 'tenant'
|
||||
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
|
||||
)
|
||||
) {
|
||||
Filament::setTenant($resolvedContext->tenant, true);
|
||||
} elseif (! $resolvedContext->hasTenant()) {
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -291,27 +240,4 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
||||
|
||||
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
|
||||
}
|
||||
|
||||
private function hasCanonicalTenantSelection(Request $request): bool
|
||||
{
|
||||
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
|
||||
}
|
||||
|
||||
private function routeTenantIsAuthorized(Tenant $tenant, User $user, int $workspaceId, string $path): bool
|
||||
{
|
||||
$pageCategory = TenantPageCategory::fromPath($path);
|
||||
$question = match ($pageCategory) {
|
||||
TenantPageCategory::TenantBound => TenantOperabilityQuestion::TenantBoundViewability,
|
||||
default => TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
};
|
||||
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: $question,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: $pageCategory->lane(),
|
||||
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
||||
)->allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -19,6 +19,8 @@
|
||||
|
||||
final class OperateHubShell
|
||||
{
|
||||
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
|
||||
|
||||
public function __construct(
|
||||
private WorkspaceContext $workspaceContext,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
@ -83,7 +85,7 @@ public function headerActions(
|
||||
|
||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
return $this->resolveActiveTenant($request);
|
||||
return $this->resolvedContext($request)->tenant;
|
||||
}
|
||||
|
||||
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
||||
@ -91,42 +93,162 @@ public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
||||
return $this->activeEntitledTenant($request);
|
||||
}
|
||||
|
||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
public function resolvedContext(?Request $request = null): ResolvedShellContext
|
||||
{
|
||||
$pageCategory = $this->pageCategory($request);
|
||||
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
|
||||
$request ??= request();
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
if ($request instanceof Request) {
|
||||
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
|
||||
|
||||
if ($cached instanceof ResolvedShellContext) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory);
|
||||
$resolved = $this->buildResolvedContext($request);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
if ($request instanceof Request) {
|
||||
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
|
||||
}
|
||||
|
||||
if ($pageCategory === TenantPageCategory::TenantBound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
||||
|
||||
if (! $rememberedTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
|
||||
$this->workspaceContext->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rememberedTenant;
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
|
||||
{
|
||||
$pageCategory = $this->pageCategory($request);
|
||||
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
||||
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
||||
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
|
||||
|
||||
$workspaceSource = match (true) {
|
||||
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
||||
$workspace instanceof Workspace && $routeTenantCandidate instanceof Tenant && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
|
||||
default => 'none',
|
||||
};
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: null,
|
||||
tenant: null,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'missing_workspace',
|
||||
displayMode: 'recovery',
|
||||
recoveryAction: $pageCategory === TenantPageCategory::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
|
||||
recoveryDestination: '/admin/choose-workspace',
|
||||
recoveryReason: 'missing_workspace',
|
||||
);
|
||||
}
|
||||
|
||||
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
|
||||
|
||||
if ($routeTenant['tenant'] instanceof Tenant) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: $routeTenant['tenant'],
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenant_scoped',
|
||||
displayMode: 'tenant_scoped',
|
||||
workspaceSource: $workspaceSource,
|
||||
tenantSource: 'route',
|
||||
);
|
||||
}
|
||||
|
||||
$recoveryReason = $routeTenant['reason'];
|
||||
|
||||
if ($pageCategory === TenantPageCategory::TenantBound && $recoveryReason !== null) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: null,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'invalid_tenant',
|
||||
displayMode: 'recovery',
|
||||
workspaceSource: $workspaceSource,
|
||||
recoveryAction: 'abort_not_found',
|
||||
recoveryReason: $recoveryReason,
|
||||
);
|
||||
}
|
||||
|
||||
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
|
||||
|
||||
if ($queryHintTenant['tenant'] instanceof Tenant) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: $queryHintTenant['tenant'],
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenant_scoped',
|
||||
displayMode: 'tenant_scoped',
|
||||
workspaceSource: $workspaceSource,
|
||||
tenantSource: 'query_hint',
|
||||
);
|
||||
}
|
||||
|
||||
$recoveryReason ??= $queryHintTenant['reason'];
|
||||
|
||||
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenant_scoped',
|
||||
displayMode: 'tenant_scoped',
|
||||
workspaceSource: $workspaceSource,
|
||||
tenantSource: 'filament_tenant',
|
||||
);
|
||||
}
|
||||
|
||||
if ($pageCategory->allowsRememberedTenantRestore()) {
|
||||
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
||||
|
||||
if ($rememberedTenant instanceof Tenant) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: $rememberedTenant,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenant_scoped',
|
||||
displayMode: 'tenant_scoped',
|
||||
workspaceSource: $workspaceSource,
|
||||
tenantSource: 'remembered',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($pageCategory->requiresExplicitTenant()) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: null,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'missing_tenant',
|
||||
displayMode: 'recovery',
|
||||
workspaceSource: $workspaceSource,
|
||||
recoveryAction: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
||||
? 'redirect_evidence_overview'
|
||||
: 'abort_not_found',
|
||||
recoveryDestination: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
||||
? '/admin/evidence/overview'
|
||||
: null,
|
||||
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
||||
);
|
||||
}
|
||||
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: null,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenantless_workspace',
|
||||
displayMode: 'tenantless',
|
||||
workspaceSource: $workspaceSource,
|
||||
recoveryReason: $recoveryReason,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveValidatedFilamentTenant(
|
||||
?Request $request = null,
|
||||
?TenantPageCategory $pageCategory = null,
|
||||
?Workspace $workspace = null,
|
||||
): ?Tenant {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -134,8 +256,9 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
|
||||
}
|
||||
|
||||
$pageCategory ??= $this->pageCategory($request);
|
||||
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
|
||||
|
||||
if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
|
||||
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
@ -144,19 +267,58 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
private function resolveValidatedRouteTenant(
|
||||
?Tenant $tenant,
|
||||
Workspace $workspace,
|
||||
?Request $request = null,
|
||||
?TenantPageCategory $pageCategory = null,
|
||||
): array {
|
||||
$pageCategory ??= $this->pageCategory($request);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['tenant' => null, 'reason' => null];
|
||||
}
|
||||
|
||||
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
|
||||
|
||||
if ($reason !== null) {
|
||||
return ['tenant' => null, 'reason' => $reason];
|
||||
}
|
||||
|
||||
return ['tenant' => $tenant, 'reason' => null];
|
||||
}
|
||||
|
||||
private function resolveValidatedQueryHintTenant(
|
||||
?Request $request,
|
||||
Workspace $workspace,
|
||||
TenantPageCategory $pageCategory,
|
||||
): array {
|
||||
if (! $pageCategory->allowsQueryTenantHints()) {
|
||||
return ['tenant' => null, 'reason' => null];
|
||||
}
|
||||
|
||||
$queryTenant = $this->resolveQueryTenantHint($request);
|
||||
|
||||
if (! $queryTenant instanceof Tenant) {
|
||||
return ['tenant' => null, 'reason' => null];
|
||||
}
|
||||
|
||||
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
|
||||
|
||||
if ($reason !== null) {
|
||||
return ['tenant' => null, 'reason' => $reason];
|
||||
}
|
||||
|
||||
return ['tenant' => $queryTenant, 'reason' => null];
|
||||
}
|
||||
|
||||
private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
{
|
||||
$route = $request?->route();
|
||||
$pageCategory ??= $this->pageCategory($request);
|
||||
|
||||
if ($route?->hasParameter('tenant')) {
|
||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
return $this->resolveTenantIdentifier($route->parameter('tenant'));
|
||||
}
|
||||
|
||||
if (
|
||||
@ -167,24 +329,35 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
return $this->resolveTenantIdentifier($route->parameter('record'));
|
||||
}
|
||||
|
||||
private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
||||
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
|
||||
{
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
$queryTenant = $request?->query('tenant');
|
||||
|
||||
if (filled($queryTenant)) {
|
||||
return $this->resolveTenantIdentifier($queryTenant);
|
||||
}
|
||||
|
||||
$routeTenant = trim((string) $routeTenant);
|
||||
$queryTenantId = $request?->query('tenant_id');
|
||||
|
||||
if ($routeTenant === '') {
|
||||
if (filled($queryTenantId)) {
|
||||
return $this->resolveTenantIdentifier($queryTenantId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
|
||||
{
|
||||
if ($tenantIdentifier instanceof Tenant) {
|
||||
return $tenantIdentifier;
|
||||
}
|
||||
|
||||
$tenantIdentifier = trim((string) $tenantIdentifier);
|
||||
|
||||
if ($tenantIdentifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -192,94 +365,58 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
||||
|
||||
return Tenant::query()
|
||||
->withTrashed()
|
||||
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
|
||||
$query->where('external_id', $routeTenant);
|
||||
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
|
||||
$query->where('external_id', $tenantIdentifier);
|
||||
|
||||
if (ctype_digit($routeTenant)) {
|
||||
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
|
||||
if (ctype_digit($tenantIdentifier)) {
|
||||
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
||||
{
|
||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||
private function tenantValidationReason(
|
||||
Tenant $tenant,
|
||||
Workspace $workspace,
|
||||
?Request $request = null,
|
||||
?TenantPageCategory $pageCategory = null,
|
||||
): ?string {
|
||||
$pageCategory ??= $this->pageCategory($request);
|
||||
|
||||
if ($pageCategory !== TenantPageCategory::TenantBound) {
|
||||
return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
|
||||
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return 'mismatched_workspace';
|
||||
}
|
||||
|
||||
return $this->evaluateOutcome(
|
||||
tenant: $tenant,
|
||||
request: $request,
|
||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
);
|
||||
}
|
||||
|
||||
private function isContextTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
||||
{
|
||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
||||
|
||||
return match ($pageCategory) {
|
||||
TenantPageCategory::TenantBound => $this->evaluateOutcome(
|
||||
tenant: $tenant,
|
||||
request: $request,
|
||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
),
|
||||
TenantPageCategory::CanonicalWorkspaceRecordViewer,
|
||||
TenantPageCategory::OnboardingWorkflow,
|
||||
TenantPageCategory::WorkspaceScoped => $this->evaluateOutcome(
|
||||
tenant: $tenant,
|
||||
request: $request,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
lane: TenantInteractionLane::AdministrativeManagement,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private function isRememberedTenantValid(Tenant $tenant, ?Request $request = null): bool
|
||||
{
|
||||
return $this->evaluateOutcome(
|
||||
tenant: $tenant,
|
||||
request: $request,
|
||||
question: TenantOperabilityQuestion::RememberedContextValidity,
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
);
|
||||
}
|
||||
|
||||
private function evaluateOutcome(
|
||||
Tenant $tenant,
|
||||
?Request $request,
|
||||
TenantOperabilityQuestion $question,
|
||||
TenantInteractionLane $lane,
|
||||
): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
return 'not_member';
|
||||
}
|
||||
|
||||
if (! $this->capabilityResolver->isMember($user, $tenant)) {
|
||||
return false;
|
||||
return 'not_member';
|
||||
}
|
||||
|
||||
return $this->tenantOperabilityService->outcomeFor(
|
||||
$question = $pageCategory === TenantPageCategory::TenantBound
|
||||
? TenantOperabilityQuestion::TenantBoundViewability
|
||||
: TenantOperabilityQuestion::AdministrativeDiscoverability;
|
||||
|
||||
$allowed = $this->tenantOperabilityService->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: $question,
|
||||
actor: $user,
|
||||
workspaceId: $workspaceId,
|
||||
lane: $lane,
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
lane: $pageCategory->lane(),
|
||||
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
|
||||
)->allowed;
|
||||
|
||||
if ($allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $pageCategory === TenantPageCategory::TenantBound
|
||||
? 'inaccessible'
|
||||
: 'not_operable';
|
||||
}
|
||||
|
||||
private function pageCategory(?Request $request = null): TenantPageCategory
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OperateHub;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
|
||||
final readonly class ResolvedShellContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?Workspace $workspace,
|
||||
public ?Tenant $tenant,
|
||||
public TenantPageCategory $pageCategory,
|
||||
public string $state,
|
||||
public string $displayMode,
|
||||
public string $workspaceSource = 'none',
|
||||
public string $tenantSource = 'none',
|
||||
public string $recoveryAction = 'none',
|
||||
public ?string $recoveryDestination = null,
|
||||
public ?string $recoveryReason = null,
|
||||
) {}
|
||||
|
||||
public function hasWorkspace(): bool
|
||||
{
|
||||
return $this->workspace instanceof Workspace;
|
||||
}
|
||||
|
||||
public function hasTenant(): bool
|
||||
{
|
||||
return $this->tenant instanceof Tenant;
|
||||
}
|
||||
|
||||
public function showsRecoveryNotice(): bool
|
||||
{
|
||||
return $this->displayMode === 'recovery' || $this->recoveryReason !== null;
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,11 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
|
||||
{
|
||||
return match ($pageCategory) {
|
||||
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
|
||||
TenantPageCategory::TenantBound => self::AdministrativeManagement,
|
||||
TenantPageCategory::TenantBound,
|
||||
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
|
||||
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
||||
TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
|
||||
TenantPageCategory::WorkspaceScoped,
|
||||
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
enum TenantPageCategory: string
|
||||
{
|
||||
case WorkspaceScoped = 'workspace_scoped';
|
||||
case WorkspaceChooserException = 'workspace_chooser_exception';
|
||||
case TenantBound = 'tenant_bound';
|
||||
case TenantScopedEvidence = 'tenant_scoped_evidence';
|
||||
case OnboardingWorkflow = 'onboarding_workflow';
|
||||
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
|
||||
|
||||
@ -26,10 +28,21 @@ public static function fromPath(string $path): self
|
||||
{
|
||||
$normalizedPath = '/'.ltrim($path, '/');
|
||||
|
||||
if ($normalizedPath === '/admin/choose-workspace') {
|
||||
return self::WorkspaceChooserException;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
|
||||
return self::CanonicalWorkspaceRecordViewer;
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($normalizedPath, '/admin/evidence/')
|
||||
&& ! str_starts_with($normalizedPath, '/admin/evidence/overview')
|
||||
) {
|
||||
return self::TenantScopedEvidence;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
|
||||
return self::OnboardingWorkflow;
|
||||
}
|
||||
@ -44,6 +57,41 @@ public static function fromPath(string $path): self
|
||||
return self::WorkspaceScoped;
|
||||
}
|
||||
|
||||
public function allowsQueryTenantHints(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceScoped, self::OnboardingWorkflow => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function allowsRememberedTenantRestore(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceScoped, self::OnboardingWorkflow, self::CanonicalWorkspaceRecordViewer => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function allowsTenantlessState(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceScoped,
|
||||
self::WorkspaceChooserException,
|
||||
self::OnboardingWorkflow,
|
||||
self::CanonicalWorkspaceRecordViewer => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresExplicitTenant(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::TenantBound, self::TenantScopedEvidence => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function lane(): TenantInteractionLane
|
||||
{
|
||||
return TenantInteractionLane::fromPageCategory($this);
|
||||
|
||||
@ -55,6 +55,33 @@ public function currentWorkspace(?Request $request = null): ?Workspace
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
public function currentWorkspaceOrTenantWorkspace(?Tenant $tenant = null, ?Request $request = null): ?Workspace
|
||||
{
|
||||
$workspace = $this->currentWorkspace($request);
|
||||
|
||||
if ($workspace instanceof Workspace) {
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = $tenant->workspace()->first();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $request?->user() instanceof User ? $request->user() : auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->isMember($user, $workspace) || ! $this->userCanAccessTenant($tenant, $request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
|
||||
{
|
||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
@ -30,8 +31,12 @@ public function __construct(
|
||||
*
|
||||
* Returns a fully qualified URL string.
|
||||
*/
|
||||
public function resolve(Workspace $workspace, User $user): string
|
||||
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string
|
||||
{
|
||||
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
|
||||
return $intendedUrl;
|
||||
}
|
||||
|
||||
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
->orderBy('name')
|
||||
@ -71,4 +76,45 @@ public function resolveFromId(int $workspaceId, User $user): string
|
||||
|
||||
return $this->resolve($workspace, $user);
|
||||
}
|
||||
|
||||
private function intendedUrlMatchesWorkspace(string $intendedUrl, Workspace $workspace, User $user): bool
|
||||
{
|
||||
$path = '/'.ltrim((string) (parse_url($intendedUrl, PHP_URL_PATH) ?? ''), '/');
|
||||
|
||||
if (! str_starts_with($path, '/admin')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/(?:t|tenants)/([^/]+)(?:/|$)#', $path, $matches) === 1) {
|
||||
return $this->tenantIdentifierMatchesWorkspace($matches[1], $workspace, $user);
|
||||
}
|
||||
|
||||
parse_str((string) (parse_url($intendedUrl, PHP_URL_QUERY) ?? ''), $query);
|
||||
|
||||
$tenantIdentifier = $query['tenant'] ?? $query['tenant_id'] ?? null;
|
||||
|
||||
if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $workspace, User $user): bool
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->withTrashed()
|
||||
->where(static function ($query) use ($identifier): void {
|
||||
$query->where('external_id', $identifier);
|
||||
|
||||
if (ctype_digit($identifier)) {
|
||||
$query->orWhereKey((int) $identifier);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||
&& $user->canAccessTenant($tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,14 +4,14 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
/** @var WorkspaceContext $workspaceContext */
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
|
||||
|
||||
$workspace = $workspaceContext->currentWorkspace(request());
|
||||
$workspace = $resolvedContext->workspace;
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -22,29 +22,17 @@
|
||||
->values();
|
||||
}
|
||||
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$currentTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$currentTenant = $resolvedContext->tenant;
|
||||
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
|
||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||
|
||||
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
|
||||
|
||||
$route = request()->route();
|
||||
$routeName = (string) ($route?->getName() ?? '');
|
||||
$pageCategory = TenantPageCategory::fromRequest(request());
|
||||
$tenantQuery = request()->query('tenant');
|
||||
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
|
||||
|
||||
$isTenantScopedRoute = $pageCategory === TenantPageCategory::TenantBound
|
||||
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
|
||||
$canClearTenantContext = $currentTenant instanceof Tenant || $lastTenantId !== null;
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$tenantLabel = $currentTenantName ?? 'All tenants';
|
||||
$workspaceLabel = $workspace?->name ?? 'Select workspace';
|
||||
$tenantLabel = $currentTenantName ?? 'No tenant selected';
|
||||
$workspaceLabel = $workspace?->name ?? 'Choose workspace';
|
||||
$hasActiveTenant = $currentTenantName !== null;
|
||||
$managedTenantsUrl = $workspace
|
||||
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
||||
@ -52,7 +40,7 @@
|
||||
$workspaceUrl = $workspace
|
||||
? route('admin.home')
|
||||
: ChooseWorkspace::getUrl(panel: 'admin');
|
||||
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
|
||||
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
|
||||
@endphp
|
||||
|
||||
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
|
||||
@ -88,6 +76,18 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
|
||||
@if ($resolvedContext->showsRecoveryNotice())
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
<div class="font-semibold">Context unavailable</div>
|
||||
|
||||
@if ($workspace)
|
||||
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
|
||||
@else
|
||||
<div>Choose a workspace to continue with a valid admin context.</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Workspace section --}}
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
@ -128,16 +128,28 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isTenantScopedRoute)
|
||||
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
||||
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
|
||||
<a
|
||||
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
||||
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
Switch tenant
|
||||
</a>
|
||||
@if ($resolvedContext->pageCategory->requiresExplicitTenant() && $hasActiveTenant)
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
||||
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName }}</span>
|
||||
<a
|
||||
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
||||
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
Switch tenant
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($canClearTenantContext)
|
||||
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||
Clear tenant scope
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@if ($tenants->isEmpty())
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows a recovery label when workspace-scoped surfaces render without an active workspace', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create(['name' => 'Recovery Tenant']);
|
||||
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Context unavailable')
|
||||
->assertSee('Choose workspace');
|
||||
});
|
||||
|
||||
it('shows explicit recovery wording when an invalid tenant hint is discarded on a workspace route', function (): void {
|
||||
$validTenant = Tenant::factory()->active()->create(['name' => 'Valid Workspace Tenant']);
|
||||
[$user, $validTenant] = createUserWithTenant(tenant: $validTenant, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
|
||||
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id,
|
||||
])
|
||||
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
|
||||
->assertOk()
|
||||
->assertSee('Context unavailable')
|
||||
->assertSee('No tenant selected')
|
||||
->assertDontSee('Tenant scope: '.$foreignTenant->name);
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -63,3 +64,36 @@
|
||||
->assertSee($workspace->name)
|
||||
->assertDontSee('name="workspace_id"', escape: false);
|
||||
});
|
||||
|
||||
test('workspace-scoped operations honor a valid tenant query hint over remembered tenant context', function () {
|
||||
$rememberedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => null,
|
||||
'status' => 'active',
|
||||
'name' => 'Remembered Topbar Tenant',
|
||||
]);
|
||||
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||
|
||||
$hintedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||
'status' => 'active',
|
||||
'name' => 'Hinted Topbar Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $rememberedTenant->workspace_id;
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]))
|
||||
->assertOk()
|
||||
->assertSee('Tenant scope: Hinted Topbar Tenant')
|
||||
->assertDontSee('Tenant scope: Remembered Topbar Tenant');
|
||||
});
|
||||
|
||||
@ -65,13 +65,11 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Select workspace')
|
||||
->assertSee('Select tenant')
|
||||
->assertSee('Choose a workspace first.')
|
||||
->assertDontSee('Search tenants…');
|
||||
});
|
||||
|
||||
it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
|
||||
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
@ -82,12 +80,10 @@
|
||||
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee($tenant->getFilamentName())
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant')
|
||||
->assertDontSee('Clear tenant scope');
|
||||
->assertSee('Clear tenant scope');
|
||||
});
|
||||
|
||||
it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
|
||||
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void {
|
||||
$currentTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'YPTW2',
|
||||
@ -117,9 +113,7 @@
|
||||
->assertOk()
|
||||
->assertSee($routedTenant->getFilamentName())
|
||||
->assertSee('Switch tenant')
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant')
|
||||
->assertDontSee('Clear tenant scope');
|
||||
->assertSee('Clear tenant scope');
|
||||
});
|
||||
|
||||
it('filters the header tenant picker to tenants the user can access', function (): void {
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Entry']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($tenant->workspace()->firstOrFail()->name)
|
||||
->assertSee('Tenant Panel Entry')
|
||||
->assertSee('Switch tenant')
|
||||
->assertSee('Clear tenant scope')
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant');
|
||||
});
|
||||
|
||||
it('keeps workspace-scoped routes tenantless when a cross-workspace tenant hint is rejected', function (): void {
|
||||
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Workspace Tenant']);
|
||||
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Rejected Foreign Tenant']);
|
||||
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
|
||||
->assertOk()
|
||||
->assertSee('No tenant selected')
|
||||
->assertDontSee('Tenant scope: Rejected Foreign Tenant');
|
||||
});
|
||||
@ -87,3 +87,43 @@
|
||||
])
|
||||
->assertRedirect('/admin/choose-workspace');
|
||||
});
|
||||
|
||||
it('returns 404 when selecting a tenant from another workspace', function (): void {
|
||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
|
||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
|
||||
createUserWithTenant(tenant: $foreignTenant, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||
->post(route('admin.select-tenant'), [
|
||||
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $activeTenant->workspace_id);
|
||||
});
|
||||
|
||||
it('returns 404 when selecting a tenant the user cannot access', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->post(route('admin.select-tenant'), [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('ignores an intended tenant route that is not valid in the target workspace', function (): void {
|
||||
$sourceTenant = Tenant::factory()->active()->create(['name' => 'Source Tenant']);
|
||||
[$user, $sourceTenant] = createUserWithTenant(tenant: $sourceTenant, role: 'owner');
|
||||
|
||||
$targetWorkspaceTenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Target Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $targetWorkspaceTenant, user: $user, role: 'owner');
|
||||
|
||||
$targetWorkspace = $targetWorkspaceTenant->workspace()->firstOrFail();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $sourceTenant->workspace_id,
|
||||
WorkspaceContext::INTENDED_URL_SESSION_KEY => "/admin/t/{$sourceTenant->external_id}",
|
||||
])
|
||||
->post(route('admin.switch-workspace'), [
|
||||
'workspace_id' => (int) $targetWorkspace->getKey(),
|
||||
]);
|
||||
|
||||
$response->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $targetWorkspaceTenant));
|
||||
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $targetWorkspace->getKey());
|
||||
});
|
||||
@ -119,3 +119,59 @@
|
||||
|
||||
expect($url)->toBe($expectedRoute);
|
||||
});
|
||||
|
||||
it('preserves a safe intended admin url that targets a tenant in the selected workspace', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]);
|
||||
|
||||
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
|
||||
|
||||
expect($url)->toBe($intendedUrl);
|
||||
});
|
||||
|
||||
it('rejects an unsafe intended admin url when its tenant hint targets another workspace', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$foreignTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]);
|
||||
|
||||
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
|
||||
|
||||
expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Select workspace')
|
||||
->assertSee('Choose workspace')
|
||||
->assertSee('Choose a workspace first.');
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prefers a valid tenant query hint over remembered tenant state on workspace-scoped admin routes', function (): void {
|
||||
$rememberedTenant = Tenant::factory()->active()->create(['name' => 'Remembered Tenant']);
|
||||
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||
|
||||
$hintedTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||
'name' => 'Hinted Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $rememberedTenant->workspace_id;
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
||||
]);
|
||||
|
||||
$request = Request::create(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]));
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(static fn () => $user);
|
||||
|
||||
$route = app('router')->getRoutes()->match($request);
|
||||
$request->setRouteResolver(static fn () => $route);
|
||||
|
||||
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||
|
||||
expect($resolved->workspace?->getKey())->toBe($workspaceId)
|
||||
->and($resolved->tenant?->is($hintedTenant))->toBeTrue()
|
||||
->and($resolved->tenantSource)->toBe('query_hint')
|
||||
->and($resolved->state)->toBe('tenant_scoped');
|
||||
});
|
||||
|
||||
it('falls back to a tenantless workspace state when a tenant query hint targets another workspace', function (): void {
|
||||
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
|
||||
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
|
||||
|
||||
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Tenant']);
|
||||
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $workspaceTenant->workspace_id;
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
|
||||
$request = Request::create(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]));
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(static fn () => $user);
|
||||
|
||||
$route = app('router')->getRoutes()->match($request);
|
||||
$request->setRouteResolver(static fn () => $route);
|
||||
|
||||
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||
|
||||
expect($resolved->workspace?->getKey())->toBe($workspaceId)
|
||||
->and($resolved->tenant)->toBeNull()
|
||||
->and($resolved->state)->toBe('tenantless_workspace')
|
||||
->and($resolved->recoveryReason)->toBe('mismatched_workspace');
|
||||
});
|
||||
|
||||
it('uses the routed tenant workspace when the tenant panel is entered without a selected workspace session', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Scope']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$request = Request::create("/admin/t/{$tenant->external_id}");
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(static fn () => $user);
|
||||
|
||||
$route = app('router')->getRoutes()->match($request);
|
||||
$request->setRouteResolver(static fn () => $route);
|
||||
|
||||
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||
|
||||
expect($resolved->workspace?->getKey())->toBe((int) $tenant->workspace_id)
|
||||
->and($resolved->tenant?->is($tenant))->toBeTrue()
|
||||
->and($resolved->workspaceSource)->toBe('route')
|
||||
->and($resolved->tenantSource)->toBe('route');
|
||||
});
|
||||
@ -11,9 +11,12 @@
|
||||
expect(TenantPageCategory::fromPath($path))->toBe($expected);
|
||||
})->with([
|
||||
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
|
||||
'workspace chooser exception' => ['/admin/choose-workspace', TenantPageCategory::WorkspaceChooserException],
|
||||
'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped],
|
||||
'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound],
|
||||
'tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::TenantBound],
|
||||
'tenant scoped evidence detail' => ['/admin/evidence/123', TenantPageCategory::TenantScopedEvidence],
|
||||
'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceScoped],
|
||||
'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
|
||||
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
|
||||
'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped],
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
site: publicSiteUrl,
|
||||
server: {
|
||||
host: true,
|
||||
port: 4321,
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -9,9 +9,18 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0 --port ${WEBSITE_PORT:-4321}",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --host 0.0.0.0"
|
||||
"preview": "astro preview --host 0.0.0.0",
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.7.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
29
apps/website/playwright.config.ts
Normal file
29
apps/website/playwright.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.WEBSITE_PORT ?? '4321');
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/smoke',
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `WEBSITE_PORT=${port} corepack pnpm dev`,
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@ -1,2 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: /sitemap.xml
|
||||
|
||||
35
apps/website/src/components/content/AudienceRow.astro
Normal file
35
apps/website/src/components/content/AudienceRow.astro
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
import SecondaryCTA from '@/components/content/SecondaryCTA.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { AudienceRowContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
item: AudienceRowContent;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<Card class="h-full">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||
{item.audience}
|
||||
</p>
|
||||
<h3 class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||
<ul class="mt-5 space-y-3 p-0">
|
||||
{
|
||||
item.bullets.map((bullet) => (
|
||||
<li class="list-none rounded-[1rem] bg-white/70 px-4 py-3 text-sm text-[var(--color-ink-800)]">
|
||||
{bullet}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{item.cta && (
|
||||
<div class="mt-6">
|
||||
<SecondaryCTA cta={item.cta} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
23
apps/website/src/components/content/Callout.astro
Normal file
23
apps/website/src/components/content/Callout.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { CalloutContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
content: CalloutContent;
|
||||
}
|
||||
|
||||
const { content } = Astro.props;
|
||||
const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default';
|
||||
---
|
||||
|
||||
<Card variant={variant}>
|
||||
{content.eyebrow && (
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||
{content.eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||
{content.title}
|
||||
</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{content.description}</p>
|
||||
</Card>
|
||||
29
apps/website/src/components/content/ContactPanel.astro
Normal file
29
apps/website/src/components/content/ContactPanel.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { CtaLink } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
cta: CtaLink;
|
||||
points: string[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { cta, points, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Card variant="accent">
|
||||
<h3 class="m-0 text-3xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{title}</h3>
|
||||
<ul class="mt-5 space-y-3 p-0">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="list-none rounded-[1rem] bg-white/72 px-4 py-3 text-sm text-[var(--color-ink-800)]">
|
||||
{point}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<Button href={cta.href} variant={cta.variant ?? 'primary'}>{cta.label}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
18
apps/website/src/components/content/DemoPrompt.astro
Normal file
18
apps/website/src/components/content/DemoPrompt.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { description, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Card>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||
Conversation focus
|
||||
</p>
|
||||
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{title}</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{description}</p>
|
||||
</Card>
|
||||
11
apps/website/src/components/content/Eyebrow.astro
Normal file
11
apps/website/src/components/content/Eyebrow.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={['m-0 text-sm font-semibold uppercase tracking-[0.18em] text-[var(--color-brand)]', className]}>
|
||||
<slot />
|
||||
</p>
|
||||
32
apps/website/src/components/content/FeatureItem.astro
Normal file
32
apps/website/src/components/content/FeatureItem.astro
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { FeatureItemContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
item: FeatureItemContent;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<Card class="h-full">
|
||||
{item.eyebrow && (
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||
{item.eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||
{(item.meta || item.href) && (
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm">
|
||||
{item.meta && <span class="text-[var(--color-brand)]">{item.meta}</span>}
|
||||
{item.href && (
|
||||
<a class="font-semibold text-[var(--color-ink-900)] underline decoration-[rgba(17,36,58,0.18)] underline-offset-4" href={item.href}>
|
||||
Learn more
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
11
apps/website/src/components/content/Headline.astro
Normal file
11
apps/website/src/components/content/Headline.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<h2 class:list={['m-0 font-[var(--font-display)] text-4xl leading-[0.98] tracking-[-0.03em] text-[var(--color-ink-900)] sm:text-5xl', className]}>
|
||||
<slot />
|
||||
</h2>
|
||||
19
apps/website/src/components/content/IntegrationBadge.astro
Normal file
19
apps/website/src/components/content/IntegrationBadge.astro
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
import Badge from '@/components/primitives/Badge.astro';
|
||||
import type { IntegrationEntry } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
item: IntegrationEntry;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="rounded-[1.1rem] border border-[rgba(17,36,58,0.08)] bg-white/78 px-4 py-3 shadow-[var(--shadow-soft)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge tone="neutral">{item.category}</Badge>
|
||||
<p class="m-0 text-base font-semibold text-[var(--color-ink-900)]">{item.name}</p>
|
||||
</div>
|
||||
<p class="mt-3 max-w-72 text-sm leading-6 text-[var(--color-copy)]">{item.summary}</p>
|
||||
{item.note && <p class="mt-2 text-xs font-medium uppercase tracking-[0.14em] text-[var(--color-brand)]">{item.note}</p>}
|
||||
</div>
|
||||
11
apps/website/src/components/content/Lead.astro
Normal file
11
apps/website/src/components/content/Lead.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={['m-0 text-base leading-8 text-[var(--color-copy)] sm:text-lg', className]}>
|
||||
<slot />
|
||||
</p>
|
||||
18
apps/website/src/components/content/Metric.astro
Normal file
18
apps/website/src/components/content/Metric.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { MetricItem } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
item: MetricItem;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<Card variant="subtle">
|
||||
<p class="m-0 text-3xl font-semibold tracking-[-0.04em] text-[var(--color-ink-900)]">{item.value}</p>
|
||||
<p class="mt-2 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-brand)]">
|
||||
{item.label}
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--color-copy)]">{item.description}</p>
|
||||
</Card>
|
||||
12
apps/website/src/components/content/PrimaryCTA.astro
Normal file
12
apps/website/src/components/content/PrimaryCTA.astro
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import type { CtaLink } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
cta: CtaLink;
|
||||
}
|
||||
|
||||
const { cta } = Astro.props;
|
||||
---
|
||||
|
||||
<Button href={cta.href} variant={cta.variant ?? 'primary'}>{cta.label}</Button>
|
||||
29
apps/website/src/components/content/RichText.astro
Normal file
29
apps/website/src/components/content/RichText.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { LegalSection } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
sections: LegalSection[];
|
||||
}
|
||||
|
||||
const { sections } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="space-y-6">
|
||||
{
|
||||
sections.map((section) => (
|
||||
<section class="rounded-[1.5rem] border border-[rgba(17,36,58,0.08)] bg-white/72 p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="m-0 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div class="legal-prose mt-4">
|
||||
{section.body.map((paragraph) => <p>{paragraph}</p>)}
|
||||
{section.bullets && section.bullets.length > 0 && (
|
||||
<ul>
|
||||
{section.bullets.map((bullet) => <li>{bullet}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
12
apps/website/src/components/content/SecondaryCTA.astro
Normal file
12
apps/website/src/components/content/SecondaryCTA.astro
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import type { CtaLink } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
cta: CtaLink;
|
||||
}
|
||||
|
||||
const { cta } = Astro.props;
|
||||
---
|
||||
|
||||
<Button href={cta.href} variant={cta.variant ?? 'secondary'}>{cta.label}</Button>
|
||||
16
apps/website/src/components/content/TrustPrincipleCard.astro
Normal file
16
apps/website/src/components/content/TrustPrincipleCard.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import type { TrustPrincipleContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
item: TrustPrincipleContent;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<Card class="h-full">
|
||||
<h3 class="m-0 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{item.title}</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||
{item.note && <p class="mt-4 text-sm font-medium text-[var(--color-brand)]">{item.note}</p>}
|
||||
</Card>
|
||||
59
apps/website/src/components/layout/Footer.astro
Normal file
59
apps/website/src/components/layout/Footer.astro
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import { contactCta, footerNavigationGroups, siteMetadata } from '@/lib/site';
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const { currentPath: _currentPath } = Astro.props;
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="section-divider pt-10 sm:pt-12">
|
||||
<Container wide>
|
||||
<div class="grid gap-8 rounded-[2rem] bg-[rgba(255,255,255,0.58)] p-6 shadow-[var(--shadow-soft)] lg:grid-cols-[1.3fr,1fr] lg:p-8">
|
||||
<div class="space-y-5">
|
||||
<p class="m-0 text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||
{siteMetadata.siteName}
|
||||
</p>
|
||||
<h2 class="m-0 max-w-xl font-[var(--font-display)] text-3xl leading-[0.98] text-[var(--color-ink-900)] sm:text-4xl">
|
||||
A calmer public surface for teams that need governance clarity before they need another dashboard.
|
||||
</h2>
|
||||
<p class="m-0 max-w-xl text-base leading-7 text-[var(--color-copy)]">
|
||||
TenantAtlas keeps product explanation, trust framing, and next-step guidance readable without hiding the product model behind hype or placeholders.
|
||||
</p>
|
||||
<Button href={contactCta.href} variant="primary" size="sm">{contactCta.label}</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
{
|
||||
footerNavigationGroups.map((group) => (
|
||||
<div>
|
||||
<p class="m-0 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-ink-900)]">
|
||||
{group.title}
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3 p-0 text-sm text-[var(--color-copy)]">
|
||||
{group.items.map((item) => (
|
||||
<li class="list-none">
|
||||
<a class="transition hover:text-[var(--color-brand)]" href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 py-6 text-sm text-[var(--color-copy)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="m-0">© {currentYear} {siteMetadata.siteName}. Public product site v0 foundation.</p>
|
||||
<p class="m-0">
|
||||
Built as a static Astro track with no platform auth, session, or API coupling.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
105
apps/website/src/components/layout/Navbar.astro
Normal file
105
apps/website/src/components/layout/Navbar.astro
Normal file
@ -0,0 +1,105 @@
|
||||
---
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import { contactCta, isActiveNavigationPath, primaryNavigation, siteMetadata } from '@/lib/site';
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const { currentPath } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-30 pt-4 sm:pt-6">
|
||||
<Container wide>
|
||||
<div
|
||||
class="glass-panel flex items-center justify-between gap-4 rounded-[1.75rem] border border-white/70 px-4 py-3 sm:px-5"
|
||||
>
|
||||
<a href="/" class="flex min-w-0 items-center gap-3 no-underline">
|
||||
<span
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,var(--color-brand),#7fa6cf)] font-[var(--font-display)] text-lg text-white"
|
||||
>
|
||||
TA
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-ink-900)]">
|
||||
{siteMetadata.siteName}
|
||||
</span>
|
||||
<span class="block truncate text-sm text-[var(--color-copy)]">
|
||||
{siteMetadata.siteTagline}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-1 lg:flex" aria-label="Primary">
|
||||
{
|
||||
primaryNavigation.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'rounded-full px-4 py-2 text-sm font-medium transition',
|
||||
isActiveNavigationPath(currentPath, item.href)
|
||||
? 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]'
|
||||
: 'text-[var(--color-ink-800)] hover:bg-white/70',
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<Button href={contactCta.href} variant="secondary" size="sm">{contactCta.label}</Button>
|
||||
</div>
|
||||
|
||||
<details class="relative lg:hidden">
|
||||
<summary
|
||||
aria-label="Open navigation menu"
|
||||
class="flex h-11 w-11 cursor-pointer list-none items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 text-[var(--color-ink-900)]"
|
||||
>
|
||||
<span class="sr-only">Open navigation menu</span>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="block h-0.5 w-4 bg-current"></span>
|
||||
<span class="block h-0.5 w-4 bg-current"></span>
|
||||
<span class="block h-0.5 w-4 bg-current"></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div
|
||||
class="glass-panel absolute right-0 top-[calc(100%+0.75rem)] w-[min(18rem,88vw)] rounded-[1.5rem] border border-white/80 p-3"
|
||||
>
|
||||
<nav class="flex flex-col gap-1" aria-label="Mobile primary">
|
||||
{
|
||||
primaryNavigation.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'rounded-[1rem] px-4 py-3 text-sm',
|
||||
isActiveNavigationPath(currentPath, item.href)
|
||||
? 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]'
|
||||
: 'text-[var(--color-ink-800)] hover:bg-white/75',
|
||||
]}
|
||||
>
|
||||
<span class="block font-semibold">{item.label}</span>
|
||||
{item.description && (
|
||||
<span class="mt-1 block text-xs text-[var(--color-copy)]">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div class="mt-2 rounded-[1rem] bg-[rgba(47,111,183,0.08)] p-3">
|
||||
<p class="m-0 text-sm font-semibold text-[var(--color-ink-900)]">
|
||||
{contactCta.label}
|
||||
</p>
|
||||
{contactCta.helper && (
|
||||
<p class="mt-1 text-sm text-[var(--color-copy)]">{contactCta.helper}</p>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
39
apps/website/src/components/layout/PageShell.astro
Normal file
39
apps/website/src/components/layout/PageShell.astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
import Footer from '@/components/layout/Footer.astro';
|
||||
import Navbar from '@/components/layout/Navbar.astro';
|
||||
import { resolveSeo } from '@/lib/seo';
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { currentPath, description, title } = Astro.props;
|
||||
const seo =
|
||||
title && description
|
||||
? resolveSeo({ description, path: currentPath, title })
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
canonicalUrl={seo?.canonicalUrl}
|
||||
openGraphTitle={seo?.ogTitle}
|
||||
openGraphDescription={seo?.ogDescription}
|
||||
robots={seo?.robots}
|
||||
>
|
||||
<div class="surface-shell min-h-screen">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[30rem] bg-[radial-gradient(circle_at_top,rgba(47,111,183,0.16),transparent_50%),radial-gradient(circle_at_top_right,rgba(59,139,120,0.14),transparent_28%)]"
|
||||
>
|
||||
</div>
|
||||
<Navbar currentPath={currentPath} />
|
||||
<main id="content" class="pb-16 sm:pb-20">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer currentPath={currentPath} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
25
apps/website/src/components/primitives/Badge.astro
Normal file
25
apps/website/src/components/primitives/Badge.astro
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
tone?: 'accent' | 'neutral' | 'signal' | 'warm';
|
||||
}
|
||||
|
||||
const { class: className = '', tone = 'accent' } = Astro.props;
|
||||
|
||||
const toneClasses = {
|
||||
accent: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]',
|
||||
neutral: 'bg-white/75 text-[var(--color-ink-800)]',
|
||||
signal: 'bg-[rgba(59,139,120,0.14)] text-[var(--color-signal)]',
|
||||
warm: 'bg-[rgba(175,109,67,0.14)] text-[var(--color-warm)]',
|
||||
};
|
||||
---
|
||||
|
||||
<span
|
||||
class:list={[
|
||||
'inline-flex w-fit items-center rounded-full px-3 py-1 text-[0.72rem] font-semibold uppercase tracking-[0.18em]',
|
||||
toneClasses[tone],
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
56
apps/website/src/components/primitives/Button.astro
Normal file
56
apps/website/src/components/primitives/Button.astro
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
import type { ButtonVariant } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
ariaLabel?: string;
|
||||
class?: string;
|
||||
href?: string;
|
||||
rel?: string;
|
||||
size?: 'lg' | 'md' | 'sm';
|
||||
target?: '_blank' | '_self';
|
||||
type?: 'button' | 'reset' | 'submit';
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
const {
|
||||
ariaLabel,
|
||||
class: className = '',
|
||||
href,
|
||||
rel,
|
||||
size = 'md',
|
||||
target,
|
||||
type = 'button',
|
||||
variant = 'primary',
|
||||
} = Astro.props;
|
||||
|
||||
const baseClass =
|
||||
'inline-flex items-center justify-center rounded-full border font-semibold tracking-[-0.01em] transition duration-150 focus-visible:outline-none';
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'min-h-10 px-4 text-sm',
|
||||
md: 'min-h-11 px-5 text-sm sm:text-[0.95rem]',
|
||||
lg: 'min-h-12 px-6 text-[0.95rem]',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_18px_38px_rgba(17,36,58,0.16)] hover:bg-[var(--color-brand)]',
|
||||
secondary:
|
||||
'border-[color:var(--color-line)] bg-white/85 text-[var(--color-ink-900)] hover:border-[var(--color-ink-900)] hover:bg-white',
|
||||
ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70',
|
||||
};
|
||||
|
||||
const classes = [baseClass, sizeClasses[size], variantClasses[variant], className];
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} target={target} rel={rel} aria-label={ariaLabel} class:list={classes}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} aria-label={ariaLabel} class:list={classes}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
23
apps/website/src/components/primitives/Card.astro
Normal file
23
apps/website/src/components/primitives/Card.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
interface Props {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
class?: string;
|
||||
variant?: 'accent' | 'default' | 'subtle';
|
||||
}
|
||||
|
||||
const { as = 'article', class: className = '', variant = 'default' } = Astro.props;
|
||||
|
||||
const variantClasses = {
|
||||
default: 'glass-panel border border-[color:var(--color-line)] bg-[var(--color-panel)]',
|
||||
accent:
|
||||
'border border-[rgba(47,111,183,0.18)] bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(238,245,252,0.94))] shadow-[var(--shadow-soft)]',
|
||||
subtle:
|
||||
'border border-[rgba(17,36,58,0.08)] bg-[linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.56))]',
|
||||
};
|
||||
|
||||
const Tag = as;
|
||||
---
|
||||
|
||||
<Tag class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}>
|
||||
<slot />
|
||||
</Tag>
|
||||
13
apps/website/src/components/primitives/Cluster.astro
Normal file
13
apps/website/src/components/primitives/Cluster.astro
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
interface Props {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { as = 'div', class: className = '' } = Astro.props;
|
||||
const Tag = as;
|
||||
---
|
||||
|
||||
<Tag class:list={['flex flex-wrap items-center gap-3 sm:gap-4', className]}>
|
||||
<slot />
|
||||
</Tag>
|
||||
14
apps/website/src/components/primitives/Container.astro
Normal file
14
apps/website/src/components/primitives/Container.astro
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
interface Props {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
class?: string;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
const { as = 'div', class: className = '', wide = false } = Astro.props;
|
||||
const Tag = as;
|
||||
---
|
||||
|
||||
<Tag class:list={['mx-auto w-full px-5 sm:px-6 lg:px-8', wide ? 'max-w-[80rem]' : 'max-w-6xl', className]}>
|
||||
<slot />
|
||||
</Tag>
|
||||
18
apps/website/src/components/primitives/Grid.astro
Normal file
18
apps/website/src/components/primitives/Grid.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
cols?: '2' | '3' | '4';
|
||||
}
|
||||
|
||||
const { class: className = '', cols = '3' } = Astro.props;
|
||||
|
||||
const colClasses = {
|
||||
'2': 'grid-cols-1 md:grid-cols-2',
|
||||
'3': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
|
||||
'4': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4',
|
||||
};
|
||||
---
|
||||
|
||||
<div class:list={['grid gap-5 lg:gap-6', colClasses[cols], className]}>
|
||||
<slot />
|
||||
</div>
|
||||
32
apps/website/src/components/primitives/Input.astro
Normal file
32
apps/website/src/components/primitives/Input.astro
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
type?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
class: className = '',
|
||||
name,
|
||||
placeholder,
|
||||
readonly = false,
|
||||
type = 'text',
|
||||
value,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readonly={readonly}
|
||||
class:list={[
|
||||
'min-h-12 w-full rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/90 px-4 text-[0.97rem] text-[var(--color-ink-900)] shadow-[var(--shadow-soft)] placeholder:text-[var(--color-copy)]/70',
|
||||
readonly ? 'cursor-default' : '',
|
||||
className,
|
||||
]}
|
||||
/>
|
||||
22
apps/website/src/components/primitives/Section.astro
Normal file
22
apps/website/src/components/primitives/Section.astro
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
class?: string;
|
||||
id?: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const { as = 'section', class: className = '', id, muted = false } = Astro.props;
|
||||
const Tag = as;
|
||||
---
|
||||
|
||||
<Tag
|
||||
id={id}
|
||||
class:list={[
|
||||
'py-12 sm:py-16 lg:py-20',
|
||||
muted ? 'bg-white/45' : '',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<slot />
|
||||
</Tag>
|
||||
27
apps/website/src/components/primitives/SectionHeader.astro
Normal file
27
apps/website/src/components/primitives/SectionHeader.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
import Eyebrow from '@/components/content/Eyebrow.astro';
|
||||
import Headline from '@/components/content/Headline.astro';
|
||||
import Lead from '@/components/content/Lead.astro';
|
||||
|
||||
interface Props {
|
||||
align?: 'center' | 'left';
|
||||
class?: string;
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const {
|
||||
align = 'left',
|
||||
class: className = '',
|
||||
description,
|
||||
eyebrow,
|
||||
title,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={['max-w-3xl', align === 'center' ? 'mx-auto text-center' : '', className]}>
|
||||
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||
<Headline>{title}</Headline>
|
||||
{description && <Lead class="mt-4">{description}</Lead>}
|
||||
</div>
|
||||
20
apps/website/src/components/primitives/Stack.astro
Normal file
20
apps/website/src/components/primitives/Stack.astro
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
class?: string;
|
||||
gap?: 'lg' | 'md' | 'sm' | 'xl';
|
||||
}
|
||||
|
||||
const { as = 'div', class: className = '', gap = 'md' } = Astro.props;
|
||||
const gapClasses = {
|
||||
sm: 'flex flex-col gap-3',
|
||||
md: 'flex flex-col gap-5',
|
||||
lg: 'flex flex-col gap-7',
|
||||
xl: 'flex flex-col gap-10',
|
||||
};
|
||||
const Tag = as;
|
||||
---
|
||||
|
||||
<Tag class:list={[gapClasses[gap], className]}>
|
||||
<slot />
|
||||
</Tag>
|
||||
31
apps/website/src/components/primitives/Textarea.astro
Normal file
31
apps/website/src/components/primitives/Textarea.astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
rows?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
class: className = '',
|
||||
name,
|
||||
placeholder,
|
||||
readonly = false,
|
||||
rows = 5,
|
||||
value,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<textarea
|
||||
name={name}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
readonly={readonly}
|
||||
class:list={[
|
||||
'min-h-32 w-full rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/90 px-4 py-3 text-[0.97rem] text-[var(--color-ink-900)] shadow-[var(--shadow-soft)] placeholder:text-[var(--color-copy)]/70',
|
||||
readonly ? 'cursor-default' : '',
|
||||
className,
|
||||
]}
|
||||
>{value}</textarea>
|
||||
33
apps/website/src/components/sections/CTASection.astro
Normal file
33
apps/website/src/components/sections/CTASection.astro
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
import SecondaryCTA from '@/components/content/SecondaryCTA.astro';
|
||||
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import type { CtaLink } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
primary: CtaLink;
|
||||
secondary?: CtaLink;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { description, eyebrow, primary, secondary, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<Card variant="accent" class="overflow-hidden">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr] lg:items-end">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||
<div class="flex flex-col gap-3 sm:flex-row lg:justify-end">
|
||||
<PrimaryCTA cta={primary} />
|
||||
{secondary && <SecondaryCTA cta={secondary} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Container>
|
||||
</Section>
|
||||
28
apps/website/src/components/sections/FeatureGrid.astro
Normal file
28
apps/website/src/components/sections/FeatureGrid.astro
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
import FeatureItem from '@/components/content/FeatureItem.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import type { FeatureItemContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
items: FeatureItemContent[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { description, eyebrow, items, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||
<Grid cols="3">
|
||||
{items.map((item) => <FeatureItem item={item} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
44
apps/website/src/components/sections/LogoStrip.astro
Normal file
44
apps/website/src/components/sections/LogoStrip.astro
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
import IntegrationBadge from '@/components/content/IntegrationBadge.astro';
|
||||
import Badge from '@/components/primitives/Badge.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import type { IntegrationEntry, LogoStripItem } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
items: (IntegrationEntry | LogoStripItem)[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { eyebrow, items, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Section class="pt-8 sm:pt-10">
|
||||
<Container wide>
|
||||
<div class="rounded-[1.8rem] border border-[rgba(17,36,58,0.08)] bg-white/55 px-5 py-6 shadow-[var(--shadow-soft)]">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
{eyebrow && <Badge tone="signal">{eyebrow}</Badge>}
|
||||
<h2 class="m-0 max-w-2xl font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{
|
||||
items.map((item) => (
|
||||
<IntegrationBadge
|
||||
item={{
|
||||
category: 'category' in item ? item.category : 'Ecosystem',
|
||||
name: item.label ?? item.name,
|
||||
note: item.note,
|
||||
summary: 'summary' in item ? item.summary : `${item.label} aligns with the launch story.`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
96
apps/website/src/components/sections/PageHero.astro
Normal file
96
apps/website/src/components/sections/PageHero.astro
Normal file
@ -0,0 +1,96 @@
|
||||
---
|
||||
import Badge from '@/components/primitives/Badge.astro';
|
||||
import Button from '@/components/primitives/Button.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import Cluster from '@/components/primitives/Cluster.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import type { HeroContent, MetricItem } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
calloutDescription?: string;
|
||||
calloutTitle?: string;
|
||||
hero: HeroContent;
|
||||
metrics?: MetricItem[];
|
||||
}
|
||||
|
||||
const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="pt-8 sm:pt-10 lg:pt-14">
|
||||
<Container wide>
|
||||
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr]">
|
||||
<Card class="motion-rise overflow-hidden">
|
||||
<div class="space-y-6">
|
||||
<Badge>{hero.eyebrow}</Badge>
|
||||
<div class="space-y-4">
|
||||
<h1 class="max-w-4xl font-[var(--font-display)] text-5xl leading-[0.93] tracking-[-0.04em] text-[var(--color-ink-900)] sm:text-6xl lg:text-7xl">
|
||||
{hero.title}
|
||||
</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-[var(--color-copy)] sm:text-xl">
|
||||
{hero.description}
|
||||
</p>
|
||||
</div>
|
||||
{(hero.primaryCta || hero.secondaryCta) && (
|
||||
<Cluster>
|
||||
<Button href={hero.primaryCta.href} variant={hero.primaryCta.variant ?? 'primary'}>
|
||||
{hero.primaryCta.label}
|
||||
</Button>
|
||||
{hero.secondaryCta && (
|
||||
<Button href={hero.secondaryCta.href} variant={hero.secondaryCta.variant ?? 'secondary'}>
|
||||
{hero.secondaryCta.label}
|
||||
</Button>
|
||||
)}
|
||||
</Cluster>
|
||||
)}
|
||||
{hero.highlights && hero.highlights.length > 0 && (
|
||||
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
||||
{
|
||||
hero.highlights.map((highlight) => (
|
||||
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
||||
{highlight}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-5">
|
||||
{(calloutTitle || calloutDescription) && (
|
||||
<Card variant="accent" class="motion-rise">
|
||||
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
||||
Trust-first launch surface
|
||||
</p>
|
||||
{calloutTitle && (
|
||||
<h2 class="mt-4 font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
|
||||
{calloutTitle}
|
||||
</h2>
|
||||
)}
|
||||
{calloutDescription && (
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{calloutDescription}</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{metrics.length > 0 && (
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{
|
||||
metrics.map((metric) => (
|
||||
<Card variant="subtle">
|
||||
<p class="m-0 text-3xl font-semibold tracking-[-0.04em] text-[var(--color-ink-900)]">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-brand)]">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--color-copy)]">{metric.description}</p>
|
||||
</Card>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
28
apps/website/src/components/sections/TrustGrid.astro
Normal file
28
apps/website/src/components/sections/TrustGrid.astro
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
import TrustPrincipleCard from '@/components/content/TrustPrincipleCard.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import type { TrustPrincipleContent } from '@/types/site';
|
||||
|
||||
interface Props {
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
items: TrustPrincipleContent[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { description, eyebrow, items, title } = Astro.props;
|
||||
---
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||
<Grid cols="3">
|
||||
{items.map((item) => <TrustPrincipleCard item={item} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
24
apps/website/src/content.config.ts
Normal file
24
apps/website/src/content.config.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { glob } from 'astro/loaders';
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const futureContentSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
publishedAt: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
articles: defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
|
||||
schema: futureContentSchema,
|
||||
}),
|
||||
changelog: defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/changelog' }),
|
||||
schema: futureContentSchema,
|
||||
}),
|
||||
resources: defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/resources' }),
|
||||
schema: futureContentSchema,
|
||||
}),
|
||||
};
|
||||
65
apps/website/src/content/pages/contact.ts
Normal file
65
apps/website/src/content/pages/contact.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||
|
||||
export const contactSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Contact',
|
||||
description:
|
||||
'TenantAtlas uses a qualified working-session path instead of a generic demo pitch so serious buyers can frame the right conversation early.',
|
||||
path: '/contact',
|
||||
};
|
||||
|
||||
export const contactHero: HeroContent = {
|
||||
eyebrow: 'Contact / Demo',
|
||||
title: 'Start a qualified working session instead of a generic demo request.',
|
||||
description:
|
||||
'The contact path should help serious buyers explain who they are, what governance questions they are trying to solve, and what kind of follow-up would actually be useful.',
|
||||
primaryCta: {
|
||||
href: 'mailto:hello@tenantatlas.example?subject=TenantAtlas%20working%20session',
|
||||
label: 'Email the TenantAtlas team',
|
||||
variant: 'primary',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/legal',
|
||||
label: 'Read the legal surface',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Use the page to qualify the conversation, not to force a form funnel.',
|
||||
'Set expectations for what the session covers and what happens next.',
|
||||
'Keep privacy and terms visible before anyone shares evaluation details.',
|
||||
],
|
||||
};
|
||||
|
||||
export const contactTopics = [
|
||||
'Evaluation of backup, restore, or version-governance workflows for Intune and Microsoft tenant operations.',
|
||||
'Questions about MSP fit, customer-facing evidence, or multi-tenant operational discipline.',
|
||||
'Internal enterprise review of change history, drift visibility, evidence collection, or restore posture.',
|
||||
];
|
||||
|
||||
export const contactPrompts = [
|
||||
{
|
||||
title: 'Good first conversation',
|
||||
description:
|
||||
'Explain the current operating model, where version history breaks down today, and which changes feel hardest to review or restore safely.',
|
||||
},
|
||||
{
|
||||
title: 'Useful context to share',
|
||||
description:
|
||||
'Team shape, tenant count, policy complexity, change frequency, and whether the first concern is restore safety, auditability, or review evidence.',
|
||||
},
|
||||
];
|
||||
|
||||
export const contactPreview = {
|
||||
message:
|
||||
'We operate Microsoft tenant governance across multiple environments and want to understand how TenantAtlas approaches version history, safer restore flows, drift visibility, and review evidence.',
|
||||
topic: 'Environment and operating model summary',
|
||||
};
|
||||
|
||||
export const contactLegalSections: LegalSection[] = [
|
||||
{
|
||||
title: 'Before you reach out',
|
||||
body: [
|
||||
'Use the legal links below before sharing evaluation details so the contact path stays trustworthy and unsurprising.',
|
||||
'The legal hub, privacy page, and public website terms remain reachable from the contact flow and the global footer.',
|
||||
],
|
||||
},
|
||||
];
|
||||
145
apps/website/src/content/pages/home.ts
Normal file
145
apps/website/src/content/pages/home.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import type {
|
||||
CalloutContent,
|
||||
FeatureItemContent,
|
||||
HeroContent,
|
||||
IntegrationEntry,
|
||||
MetricItem,
|
||||
PageSeo,
|
||||
} from '@/types/site';
|
||||
|
||||
export const homeSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Governance of record for Microsoft tenant operations',
|
||||
description:
|
||||
'Trust-first public framing for a Microsoft tenant governance product that connects backup, restore, version history, drift, findings, evidence, and reviews.',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
export const homeHero: HeroContent = {
|
||||
eyebrow: 'Public website v0',
|
||||
title: 'TenantAtlas is the trust-first public site for Microsoft tenant change history, drift visibility, and safer operations.',
|
||||
description:
|
||||
'TenantAtlas gives MSP and enterprise teams one clear operating model for understanding what changed, what drifted, what needs review, and what can be restored without turning governance into a loose collection of disconnected tools.',
|
||||
primaryCta: {
|
||||
href: '/product',
|
||||
label: 'See the product model',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/security-trust',
|
||||
label: 'Review the trust posture',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Static, readable, and separate from app runtime concerns.',
|
||||
'Built for trust conversations before a demo ever starts.',
|
||||
'Designed to scale into docs, changelog, and deeper resources later.',
|
||||
],
|
||||
};
|
||||
|
||||
export const homeMetrics: MetricItem[] = [
|
||||
{
|
||||
value: '1',
|
||||
label: 'Connected model',
|
||||
description: 'Inventory, snapshots, review evidence, and restore posture stay in one narrative.',
|
||||
},
|
||||
{
|
||||
value: '7+',
|
||||
label: 'Core public surfaces',
|
||||
description: 'Visitors can move from explanation to trust and contact without dead ends.',
|
||||
},
|
||||
{
|
||||
value: '0',
|
||||
label: 'Runtime coupling',
|
||||
description: 'The website stays independent from platform auth, session, and API behavior.',
|
||||
},
|
||||
];
|
||||
|
||||
export const homePillars: FeatureItemContent[] = [
|
||||
{
|
||||
eyebrow: 'Inventory',
|
||||
title: 'Normalize what the tenant really looks like right now.',
|
||||
description:
|
||||
'Start with the observed state so teams can inspect the current configuration baseline before they talk about restore or enforcement.',
|
||||
meta: 'Last observed truth',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Snapshots',
|
||||
title: 'Keep immutable history instead of vague memory.',
|
||||
description:
|
||||
'Version history stays queryable by tenant, operator, and moment in time so teams can explain what changed and why.',
|
||||
meta: 'Reproducible versions',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Drift & findings',
|
||||
title: 'Surface drift, exceptions, and review needs in the same language.',
|
||||
description:
|
||||
'Operational questions move from “what broke?” to “what changed, what matters, and what review is due?”',
|
||||
meta: 'Review-oriented visibility',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Restore',
|
||||
title: 'Treat rollback and restore as governed actions, not panic buttons.',
|
||||
description:
|
||||
'Preview, validation, and operator confirmation stay central so risky changes are reversible without becoming casual.',
|
||||
meta: 'Safer execution',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Evidence',
|
||||
title: 'Connect reviews, findings, and evidence without a second reporting layer.',
|
||||
description:
|
||||
'Teams can show why a configuration is acceptable, where exceptions exist, and how review decisions stay attributable.',
|
||||
meta: 'Audit-ready context',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Operations',
|
||||
title: 'Keep velocity without hiding risk.',
|
||||
description:
|
||||
'The product is built for admins who need speed and auditability at the same time, not for dashboards that only summarize after the fact.',
|
||||
meta: 'Operator-first workflows',
|
||||
},
|
||||
];
|
||||
|
||||
export const homeProofBlocks: CalloutContent[] = [
|
||||
{
|
||||
eyebrow: 'Positioning',
|
||||
title: 'Governance of record for Microsoft tenant operations.',
|
||||
description:
|
||||
'The site makes the category legible up front: not just backup, not just reporting, and not a second admin portal trying to mirror every Microsoft screen.',
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Why it matters now',
|
||||
title: 'Microsoft tenant change volume keeps climbing while operator certainty keeps shrinking.',
|
||||
description:
|
||||
'When policy history, restore posture, findings, and evidence live in separate conversations, teams lose time exactly when they need clarity.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Public promise',
|
||||
title: 'No inflated compliance or automation claims.',
|
||||
description:
|
||||
'The public story stays grounded in what the product can honestly support at launch: version truth, safer restore flows, drift visibility, and review support.',
|
||||
tone: 'subtle',
|
||||
},
|
||||
];
|
||||
|
||||
export const homeEcosystem: IntegrationEntry[] = [
|
||||
{
|
||||
category: 'Microsoft',
|
||||
name: 'Microsoft Graph',
|
||||
summary: 'Graph-backed inventory and restore direction without pretending the website depends on live tenant access.',
|
||||
},
|
||||
{
|
||||
category: 'Identity',
|
||||
name: 'Entra ID',
|
||||
summary: 'Identity and access context remain part of the governance narrative where they matter to change control.',
|
||||
},
|
||||
{
|
||||
category: 'Endpoint',
|
||||
name: 'Intune',
|
||||
summary: 'Configuration state, backup, and restore posture stay central to the public product story.',
|
||||
},
|
||||
{
|
||||
category: 'Review',
|
||||
name: 'Evidence workflows',
|
||||
summary: 'Review packs, exceptions, and evidence stay connected to operational reality instead of becoming detached reporting artifacts.',
|
||||
},
|
||||
];
|
||||
86
apps/website/src/content/pages/integrations.ts
Normal file
86
apps/website/src/content/pages/integrations.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type {
|
||||
FeatureItemContent,
|
||||
HeroContent,
|
||||
IntegrationEntry,
|
||||
PageSeo,
|
||||
} from '@/types/site';
|
||||
|
||||
export const integrationsSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Integrations',
|
||||
description:
|
||||
'TenantAtlas describes the Microsoft-centric ecosystem it fits today without turning the page into a public wishlist.',
|
||||
path: '/integrations',
|
||||
};
|
||||
|
||||
export const integrationsHero: HeroContent = {
|
||||
eyebrow: 'Ecosystem fit',
|
||||
title: 'Stay clear about the ecosystem fit without turning the page into a wishlist.',
|
||||
description:
|
||||
'This page should show the real systems TenantAtlas is built around, the workflows it expects to support, and the deliberate boundaries where speculative integration language would create more noise than trust.',
|
||||
primaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Plan the working session',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/product',
|
||||
label: 'Revisit the product model',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Microsoft-first and governance-led.',
|
||||
'Real direction only, no catalog-padding.',
|
||||
'Explains fit without implying runtime coupling to the public site.',
|
||||
],
|
||||
};
|
||||
|
||||
export const integrationEntries: IntegrationEntry[] = [
|
||||
{
|
||||
category: 'Microsoft core',
|
||||
name: 'Microsoft Graph',
|
||||
summary:
|
||||
'Graph remains the primary contract path for inventory, history, and restore-oriented product behavior in the platform.',
|
||||
note: 'Core product contract',
|
||||
},
|
||||
{
|
||||
category: 'Identity',
|
||||
name: 'Entra ID',
|
||||
summary:
|
||||
'Identity context matters where tenant change, privileged access, or governance reviews intersect with Microsoft tenant administration.',
|
||||
note: 'Operational context',
|
||||
},
|
||||
{
|
||||
category: 'Endpoint',
|
||||
name: 'Intune',
|
||||
summary:
|
||||
'Intune configuration state is central to the current product story, especially for version history, backup, restore, and drift visibility.',
|
||||
note: 'Current release truth',
|
||||
},
|
||||
{
|
||||
category: 'Evidence',
|
||||
name: 'Review & evidence workflows',
|
||||
summary:
|
||||
'Exports, review packs, and evidence-linked conversations should remain grounded in the actual tenant object and its change history.',
|
||||
note: 'Governance workflow fit',
|
||||
},
|
||||
];
|
||||
|
||||
export const integrationRules: FeatureItemContent[] = [
|
||||
{
|
||||
eyebrow: 'Direction',
|
||||
title: 'Integrations should reinforce the governance model.',
|
||||
description:
|
||||
'The page is not a marketplace list. It should show which systems matter because they change the product workflow, evidence story, or operator context.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Boundaries',
|
||||
title: 'Do not imply shared public-site runtime dependencies.',
|
||||
description:
|
||||
'The website stays static and independent even while the product story references Graph, Intune, and Microsoft tenant governance flows.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Credibility',
|
||||
title: 'Speculative wishlist entries reduce trust instead of creating momentum.',
|
||||
description:
|
||||
'The public integrations page should stay shorter and sharper than a catalog full of future ideas that are not relevant to launch truth.',
|
||||
},
|
||||
];
|
||||
44
apps/website/src/content/pages/legal.ts
Normal file
44
apps/website/src/content/pages/legal.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||
|
||||
export const legalSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Legal',
|
||||
description:
|
||||
'The TenantAtlas legal surface keeps privacy, website terms, and public legal-notice routing accessible before or during buyer conversations.',
|
||||
path: '/legal',
|
||||
};
|
||||
|
||||
export const legalHero: HeroContent = {
|
||||
eyebrow: 'Legal surface',
|
||||
title: 'Legal access should stay one click away from the contact path.',
|
||||
description:
|
||||
'The legal hub keeps privacy, website terms, and public legal-notice information discoverable from the footer and the conversion flow so visitors do not have to guess where those basics live.',
|
||||
primaryCta: {
|
||||
href: '/privacy',
|
||||
label: 'Privacy',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/terms',
|
||||
label: 'Terms',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Public legal basics stay reachable before a visitor shares evaluation context.',
|
||||
'The site separates website disclosures from future product-commercial paperwork.',
|
||||
'Jurisdiction-specific notice has a dedicated home in the legal surface.',
|
||||
],
|
||||
};
|
||||
|
||||
export const legalNoticeSections: LegalSection[] = [
|
||||
{
|
||||
title: 'Public legal notice',
|
||||
body: [
|
||||
'This v0 legal surface reserves the public location for operating-entity, registration, address, and jurisdiction-specific disclosure details that must be published before a broad public launch.',
|
||||
'During controlled evaluation, legal and privacy inquiries can be routed through the public contact path while those final publisher details are being finalized.',
|
||||
],
|
||||
bullets: [
|
||||
'Operating entity and jurisdictional disclosure fields belong in this legal hub before launch.',
|
||||
'Privacy and website terms stay published as standalone routes now.',
|
||||
'The legal hub remains the single public path for future launch-required disclosures.',
|
||||
],
|
||||
},
|
||||
];
|
||||
60
apps/website/src/content/pages/privacy.ts
Normal file
60
apps/website/src/content/pages/privacy.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||
|
||||
export const privacySeo: PageSeo = {
|
||||
title: 'TenantAtlas | Privacy',
|
||||
description:
|
||||
'Public-site privacy overview for TenantAtlas inquiries, including how contact details and evaluation context are handled on the public website.',
|
||||
path: '/privacy',
|
||||
};
|
||||
|
||||
export const privacyHero: HeroContent = {
|
||||
eyebrow: 'Privacy',
|
||||
title: 'Public-site privacy overview for TenantAtlas inquiries.',
|
||||
description:
|
||||
'This page explains the privacy expectations for the public website and the contact path, rather than promising a full product-tenant data processing agreement from a static marketing surface.',
|
||||
primaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Return to contact',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/terms',
|
||||
label: 'Review website terms',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'The public site should only request information that supports a useful follow-up.',
|
||||
'Contact details and evaluation context should be handled carefully and minimally.',
|
||||
'Future product-processing details belong in product/legal agreements, not hidden marketing copy.',
|
||||
],
|
||||
};
|
||||
|
||||
export const privacySections: LegalSection[] = [
|
||||
{
|
||||
title: 'Scope',
|
||||
body: [
|
||||
'This privacy overview applies to the public TenantAtlas website and to information a visitor intentionally shares through the public contact path.',
|
||||
'It does not describe tenant data processing inside the product itself, which belongs in product-specific legal and contractual materials.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Information you choose to send',
|
||||
body: [
|
||||
'If you contact the team, the information you provide may include your name, company, role, email address, and the evaluation or governance questions you want to discuss.',
|
||||
'The site should not ask for unnecessary secrets, production credentials, or tenant data through the public contact path.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Use and retention',
|
||||
body: [
|
||||
'Information shared through the public contact path is used to understand the inquiry, respond to the request, and coordinate a relevant follow-up conversation.',
|
||||
'Public-site inquiry information should be retained only for as long as needed to manage the evaluation discussion and related follow-up.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Questions and updates',
|
||||
body: [
|
||||
'Privacy questions can be routed through the public contact path until the final launch legal notice publishes the full operating-entity details for privacy correspondence.',
|
||||
'If the public-site data handling model changes materially, this page should be updated before or at the same time as the change.',
|
||||
],
|
||||
},
|
||||
];
|
||||
110
apps/website/src/content/pages/product.ts
Normal file
110
apps/website/src/content/pages/product.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type {
|
||||
CalloutContent,
|
||||
FeatureItemContent,
|
||||
HeroContent,
|
||||
MetricItem,
|
||||
PageSeo,
|
||||
} from '@/types/site';
|
||||
|
||||
export const productSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Product',
|
||||
description:
|
||||
'TenantAtlas connects inventory, snapshots, restore safety, drift visibility, findings, exceptions, and evidence into one governance model.',
|
||||
path: '/product',
|
||||
};
|
||||
|
||||
export const productHero: HeroContent = {
|
||||
eyebrow: 'Product model',
|
||||
title: 'One operating model for change history, drift visibility, and review readiness.',
|
||||
description:
|
||||
'TenantAtlas treats Microsoft tenant governance as one connected system: observe the current state, preserve immutable history, detect meaningful change, and support reviews or restores with the context operators actually need.',
|
||||
primaryCta: {
|
||||
href: '/solutions',
|
||||
label: 'See audience fit',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Talk through your current operating model',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Inventory first, snapshots second.',
|
||||
'Restore flows stay previewable and attributable.',
|
||||
'Evidence and review posture stay connected to real change history.',
|
||||
],
|
||||
};
|
||||
|
||||
export const productMetrics: MetricItem[] = [
|
||||
{
|
||||
value: '4',
|
||||
label: 'Operator questions',
|
||||
description: 'What changed? Why does it matter? What can be restored? What needs review now?',
|
||||
},
|
||||
{
|
||||
value: '100%',
|
||||
label: 'Queryable versions',
|
||||
description: 'Version semantics stay tied to who changed what, when, and in which tenant context.',
|
||||
},
|
||||
];
|
||||
|
||||
export const productModelBlocks: FeatureItemContent[] = [
|
||||
{
|
||||
eyebrow: 'Connected governance model',
|
||||
title: 'Inventory creates the starting point for every other decision.',
|
||||
description:
|
||||
'The product begins with the last observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Connected governance model',
|
||||
title: 'Snapshots add immutable history without replacing current truth.',
|
||||
description:
|
||||
'Backups and versions are explicit artifacts. They preserve what was seen at a point in time while keeping the present-tense inventory readable.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Connected governance model',
|
||||
title: 'Restore is handled as a governed operation, not as a blind push.',
|
||||
description:
|
||||
'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Drift visibility',
|
||||
title: 'Differences become reviewable signals instead of noisy raw deltas.',
|
||||
description:
|
||||
'Human-readable summaries and structured differences help operators and reviewers decide what changed and what needs action.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Exceptions & evidence',
|
||||
title: 'Findings, exceptions, and evidence stay anchored to operational truth.',
|
||||
description:
|
||||
'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Operator safety',
|
||||
title: 'Auditability is part of the product shape, not a later add-on.',
|
||||
description:
|
||||
'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.',
|
||||
},
|
||||
];
|
||||
|
||||
export const productNarrative: CalloutContent[] = [
|
||||
{
|
||||
eyebrow: 'Why it is not a feature list',
|
||||
title: 'The point is not “backup plus reporting plus restore.”',
|
||||
description:
|
||||
'The point is to reduce operator uncertainty by keeping those capabilities connected through the same source material and the same decision flow.',
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
eyebrow: 'What teams get',
|
||||
title: 'A calmer path from observation to action.',
|
||||
description:
|
||||
'Teams can move from understanding the current tenant state to comparing history, planning remediation, or reviewing restore options without leaving the product model behind.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'What teams avoid',
|
||||
title: 'No generic dashboard theater.',
|
||||
description:
|
||||
'The product story avoids pretending that another alerting page or compliance badge alone solves governance discipline.',
|
||||
tone: 'subtle',
|
||||
},
|
||||
];
|
||||
78
apps/website/src/content/pages/security-trust.ts
Normal file
78
apps/website/src/content/pages/security-trust.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CalloutContent,
|
||||
HeroContent,
|
||||
PageSeo,
|
||||
TrustPrincipleContent,
|
||||
} from '@/types/site';
|
||||
|
||||
export const securityTrustSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Security & Trust',
|
||||
description:
|
||||
'TenantAtlas frames trust through substantiated product posture, safer restore discipline, and operational clarity rather than inflated guarantees.',
|
||||
path: '/security-trust',
|
||||
};
|
||||
|
||||
export const securityTrustHero: HeroContent = {
|
||||
eyebrow: 'Security & Trust',
|
||||
title: 'Explain the trust posture in the language of operational controls, not marketing claims.',
|
||||
description:
|
||||
'The public trust page should set realistic expectations: what the product helps teams observe, preserve, review, and restore, and where launch claims intentionally stay narrow until they can be substantiated further.',
|
||||
primaryCta: {
|
||||
href: '/legal',
|
||||
label: 'Read the legal surface',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Discuss trust requirements',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Preview, confirmation, and auditability remain part of the restore story.',
|
||||
'Public claims stay narrower than internal ambition.',
|
||||
'No fake compliance theater for launch.',
|
||||
],
|
||||
};
|
||||
|
||||
export const securityPrinciples: TrustPrincipleContent[] = [
|
||||
{
|
||||
title: 'Safer changes are a product rule, not an afterthought.',
|
||||
description:
|
||||
'Destructive or high-risk flows are framed around preview, validation, and explicit confirmation instead of one-click confidence theater.',
|
||||
note: 'Trust starts with operator discipline.',
|
||||
},
|
||||
{
|
||||
title: 'Operational evidence stays tied to the real object and version.',
|
||||
description:
|
||||
'Findings, exceptions, and reviews remain anchored to observable tenant state so the trust story is defensible when someone asks for proof.',
|
||||
note: 'Evidence should stay attributable.',
|
||||
},
|
||||
{
|
||||
title: 'Launch messaging stays within substantiated boundaries.',
|
||||
description:
|
||||
'The public site avoids promising certifications, guarantees, or full automation outcomes that are not yet appropriate to claim.',
|
||||
note: 'Restraint is part of credibility.',
|
||||
},
|
||||
];
|
||||
|
||||
export const securityTrustNotes: CalloutContent[] = [
|
||||
{
|
||||
eyebrow: 'Substantiated public posture',
|
||||
title: 'Substantiated public posture',
|
||||
description:
|
||||
'The launch story focuses on product shape and operator safeguards: inventory truth, immutable snapshots, safer restore flows, drift visibility, and review support.',
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Sensitive connections',
|
||||
title: 'Sensitive Microsoft connections should be explained carefully.',
|
||||
description:
|
||||
'The public site acknowledges Graph- and tenant-facing access without pretending the site itself is part of the runtime trust boundary.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'What we will not say',
|
||||
title: 'No blanket assurances and no vague “fully automated governance” language.',
|
||||
description:
|
||||
'Trust pages lose credibility quickly when they substitute slogans for the actual controls and workflows a buyer will later inspect.',
|
||||
tone: 'subtle',
|
||||
},
|
||||
];
|
||||
90
apps/website/src/content/pages/solutions.ts
Normal file
90
apps/website/src/content/pages/solutions.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type {
|
||||
AudienceRowContent,
|
||||
FeatureItemContent,
|
||||
HeroContent,
|
||||
PageSeo,
|
||||
} from '@/types/site';
|
||||
|
||||
export const solutionsSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Solutions',
|
||||
description:
|
||||
'TenantAtlas fits MSP and enterprise IT teams differently, and the public story should make those operating-model differences explicit.',
|
||||
path: '/solutions',
|
||||
};
|
||||
|
||||
export const solutionsHero: HeroContent = {
|
||||
eyebrow: 'Audience fit',
|
||||
title: 'Show how TenantAtlas fits MSP delivery teams and enterprise operators without collapsing them into one generic story.',
|
||||
description:
|
||||
'The product helps different organizations answer similar governance questions, but the surrounding workflow, accountability, and evidence needs are not identical. The site should acknowledge that directly.',
|
||||
primaryCta: {
|
||||
href: '/integrations',
|
||||
label: 'Review the ecosystem fit',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Talk through your evaluation path',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Separate MSP and enterprise language on purpose.',
|
||||
'Keep the product story stable while the buying context changes.',
|
||||
'Avoid forcing every visitor through the same generic motion.',
|
||||
],
|
||||
};
|
||||
|
||||
export const solutionsAudiences: AudienceRowContent[] = [
|
||||
{
|
||||
audience: 'MSP',
|
||||
title: 'MSP operating model',
|
||||
description:
|
||||
'Managed service providers need tenant-scoped operational truth, repeatable review workflows, and a way to explain change history to customers without drowning in manual evidence gathering.',
|
||||
bullets: [
|
||||
'Keep per-tenant history and restore posture reviewable during service delivery.',
|
||||
'Support a higher tempo of customer change while preserving a clean audit story.',
|
||||
'Give account and delivery teams a shared language for exceptions, findings, and follow-up.',
|
||||
],
|
||||
cta: {
|
||||
href: '/contact',
|
||||
label: 'Discuss MSP delivery fit',
|
||||
variant: 'secondary',
|
||||
},
|
||||
},
|
||||
{
|
||||
audience: 'Enterprise IT',
|
||||
title: 'Enterprise IT operating model',
|
||||
description:
|
||||
'Internal IT and security teams need durable version truth, change visibility, and review evidence that can stand up to operational leadership, audit, and cross-team scrutiny.',
|
||||
bullets: [
|
||||
'Reduce uncertainty around who changed what and when across the Microsoft tenant surface.',
|
||||
'Support internal review packs, exception handling, and evidence collection without fragmented tooling.',
|
||||
'Keep restore and remediation conversations grounded in the current tenant state and the relevant history.',
|
||||
],
|
||||
cta: {
|
||||
href: '/security-trust',
|
||||
label: 'Inspect the trust posture',
|
||||
variant: 'secondary',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const solutionsSignals: FeatureItemContent[] = [
|
||||
{
|
||||
eyebrow: 'Why buyers care',
|
||||
title: 'The product is serious about both velocity and control.',
|
||||
description:
|
||||
'Teams can move quickly without giving up visibility, confirmation discipline, or explainability when a risky change needs review.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Where it lands',
|
||||
title: 'The product belongs in the operating layer, not just the reporting layer.',
|
||||
description:
|
||||
'Visitors should understand that TenantAtlas helps teams make safer decisions about configuration state rather than merely summarize activity afterward.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'How it reads',
|
||||
title: 'The story changes by audience, but the product truth does not.',
|
||||
description:
|
||||
'MSP and enterprise readers see their own operating concerns reflected without the site inventing two different products.',
|
||||
},
|
||||
];
|
||||
60
apps/website/src/content/pages/terms.ts
Normal file
60
apps/website/src/content/pages/terms.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||
|
||||
export const termsSeo: PageSeo = {
|
||||
title: 'TenantAtlas | Terms',
|
||||
description:
|
||||
'Website terms for the public TenantAtlas surface, covering informational use of the site and the limits of public product statements.',
|
||||
path: '/terms',
|
||||
};
|
||||
|
||||
export const termsHero: HeroContent = {
|
||||
eyebrow: 'Website terms',
|
||||
title: 'Website terms for the public TenantAtlas surface.',
|
||||
description:
|
||||
'These terms describe the public website itself: informational use of the content, basic conduct expectations, and the fact that a public product site is not the same thing as a signed service agreement.',
|
||||
primaryCta: {
|
||||
href: '/contact',
|
||||
label: 'Return to contact',
|
||||
},
|
||||
secondaryCta: {
|
||||
href: '/privacy',
|
||||
label: 'Review privacy',
|
||||
variant: 'secondary',
|
||||
},
|
||||
highlights: [
|
||||
'Public copy explains the product but does not replace commercial agreements.',
|
||||
'The site is for evaluation and information, not operational control of a tenant.',
|
||||
'Any future service commitment belongs in explicit signed terms.',
|
||||
],
|
||||
};
|
||||
|
||||
export const termsSections: LegalSection[] = [
|
||||
{
|
||||
title: 'Informational website use',
|
||||
body: [
|
||||
'The public TenantAtlas website is provided to explain the product category, trust posture, integrations direction, and contact path for evaluation conversations.',
|
||||
'Nothing on the public site should be interpreted as a guarantee of product availability, certification, or commercial commitment unless it is later confirmed in signed agreements.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reasonable reliance',
|
||||
body: [
|
||||
'Visitors may use the public site to understand the product and decide whether to start a conversation, but they should not rely on public marketing pages as the sole source of contractual or implementation truth.',
|
||||
'Detailed service commitments, security terms, and procurement obligations belong in later commercial and legal documentation.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Acceptable conduct',
|
||||
body: [
|
||||
'Visitors should use the public website lawfully and should not attempt to interfere with the availability or integrity of the public site.',
|
||||
'The public website is not a runtime administration surface and should not be treated as one.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Questions',
|
||||
body: [
|
||||
'Questions about the public website terms, privacy, or future product legal materials can be routed through the public contact path.',
|
||||
'The legal hub remains the public anchor for later launch-ready legal disclosures.',
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -1,15 +1,26 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
import { siteMetadata } from '@/lib/site';
|
||||
|
||||
interface Props {
|
||||
canonicalUrl?: string;
|
||||
description?: string;
|
||||
openGraphDescription?: string;
|
||||
openGraphTitle?: string;
|
||||
robots?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
description = 'TenantPilot keeps Intune governance observable, reviewable, and safe to operate.',
|
||||
title = 'TenantPilot',
|
||||
canonicalUrl,
|
||||
description = siteMetadata.siteDescription,
|
||||
robots = 'index,follow',
|
||||
title = `${siteMetadata.siteName} | ${siteMetadata.siteTagline}`,
|
||||
} = Astro.props;
|
||||
|
||||
const openGraphTitle = Astro.props.openGraphTitle ?? title;
|
||||
const openGraphDescription = Astro.props.openGraphDescription ?? description;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@ -17,11 +28,22 @@ const {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content={robots} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta property="og:site_name" content={siteMetadata.siteName} />
|
||||
<meta property="og:title" content={openGraphTitle} />
|
||||
<meta property="og:description" content={openGraphDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={openGraphTitle} />
|
||||
<meta name="twitter:description" content={openGraphDescription} />
|
||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
apps/website/src/lib/seo.ts
Normal file
27
apps/website/src/lib/seo.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { coreRoutes, siteMetadata } from '@/lib/site';
|
||||
import type { PageSeo } from '@/types/site';
|
||||
|
||||
export interface ResolvedSeo extends PageSeo {
|
||||
canonicalUrl: string;
|
||||
ogDescription: string;
|
||||
ogTitle: string;
|
||||
robots: string;
|
||||
}
|
||||
|
||||
export function buildCanonicalUrl(path: string): string {
|
||||
return new URL(path, siteMetadata.siteUrl).toString();
|
||||
}
|
||||
|
||||
export function resolveSeo(seo: PageSeo): ResolvedSeo {
|
||||
return {
|
||||
...seo,
|
||||
canonicalUrl: buildCanonicalUrl(seo.path),
|
||||
ogDescription: seo.ogDescription ?? seo.description,
|
||||
ogTitle: seo.ogTitle ?? seo.title,
|
||||
robots: 'index,follow',
|
||||
};
|
||||
}
|
||||
|
||||
export function sitemapEntries(): string[] {
|
||||
return [...coreRoutes].map((path) => buildCanonicalUrl(path));
|
||||
}
|
||||
76
apps/website/src/lib/site.ts
Normal file
76
apps/website/src/lib/site.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type {
|
||||
CtaLink,
|
||||
FooterNavigationGroup,
|
||||
NavigationItem,
|
||||
SiteMetadata,
|
||||
} from '@/types/site';
|
||||
|
||||
export const siteMetadata: SiteMetadata = {
|
||||
siteName: 'TenantAtlas',
|
||||
siteTagline: 'Governance of record for Microsoft tenant operations.',
|
||||
siteDescription:
|
||||
'TenantAtlas helps MSP and enterprise teams keep Microsoft tenant change history observable, reviewable, and safer to operate.',
|
||||
siteUrl: import.meta.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example',
|
||||
};
|
||||
|
||||
export const primaryNavigation: NavigationItem[] = [
|
||||
{ href: '/product', label: 'Product', description: 'Understand the operating model.' },
|
||||
{ href: '/solutions', label: 'Solutions', description: 'See the fit for MSP and enterprise teams.' },
|
||||
{ href: '/security-trust', label: 'Security & Trust', description: 'Review the product posture.' },
|
||||
{ href: '/integrations', label: 'Integrations', description: 'Inspect the real ecosystem fit.' },
|
||||
{ href: '/contact', label: 'Contact', description: 'Reach the team for a working session.' },
|
||||
];
|
||||
|
||||
export const footerNavigationGroups: FooterNavigationGroup[] = [
|
||||
{
|
||||
title: 'Explore',
|
||||
items: [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/product', label: 'Product' },
|
||||
{ href: '/solutions', label: 'Solutions' },
|
||||
{ href: '/security-trust', label: 'Security & Trust' },
|
||||
{ href: '/integrations', label: 'Integrations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Next step',
|
||||
items: [
|
||||
{ href: '/contact', label: 'Contact / Demo' },
|
||||
{ href: '/legal', label: 'Legal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
items: [
|
||||
{ href: '/privacy', label: 'Privacy' },
|
||||
{ href: '/terms', label: 'Terms' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const contactCta: CtaLink = {
|
||||
href: '/contact',
|
||||
label: 'Request a working session',
|
||||
helper: 'Bring your governance questions, rollout concerns, or evaluation goals.',
|
||||
variant: 'primary',
|
||||
};
|
||||
|
||||
export const coreRoutes = [
|
||||
'/',
|
||||
'/product',
|
||||
'/solutions',
|
||||
'/security-trust',
|
||||
'/integrations',
|
||||
'/contact',
|
||||
'/legal',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
] as const;
|
||||
|
||||
export function isActiveNavigationPath(currentPath: string, href: string): boolean {
|
||||
if (href === '/') {
|
||||
return currentPath === '/';
|
||||
}
|
||||
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
98
apps/website/src/pages/contact.astro
Normal file
98
apps/website/src/pages/contact.astro
Normal file
@ -0,0 +1,98 @@
|
||||
---
|
||||
import ContactPanel from '@/components/content/ContactPanel.astro';
|
||||
import DemoPrompt from '@/components/content/DemoPrompt.astro';
|
||||
import RichText from '@/components/content/RichText.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import Cluster from '@/components/primitives/Cluster.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Input from '@/components/primitives/Input.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import Textarea from '@/components/primitives/Textarea.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import {
|
||||
contactHero,
|
||||
contactLegalSections,
|
||||
contactPreview,
|
||||
contactPrompts,
|
||||
contactSeo,
|
||||
contactTopics,
|
||||
} from '@/content/pages/contact';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/contact" title={contactSeo.title} description={contactSeo.description}>
|
||||
<PageHero
|
||||
hero={contactHero}
|
||||
calloutTitle="Qualified conversations beat anonymous form funnels."
|
||||
calloutDescription="The page should make it obvious who should reach out, why, and what a useful first exchange looks like."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<Grid cols="3">
|
||||
<ContactPanel cta={contactHero.primaryCta} points={contactTopics} title="Who should get in touch" />
|
||||
{contactPrompts.map((prompt) => <DemoPrompt title={prompt.title} description={prompt.description} />)}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,0.8fr]">
|
||||
<Card>
|
||||
<SectionHeader
|
||||
eyebrow="Suggested note"
|
||||
title="Give the team enough context to make the first reply useful."
|
||||
description="The public path stays static in v0, but it can still help a serious buyer structure the first message."
|
||||
/>
|
||||
<div class="mt-6 space-y-4">
|
||||
<Input readonly value={contactPreview.topic} />
|
||||
<Textarea readonly rows={7} value={contactPreview.message} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="accent">
|
||||
<SectionHeader
|
||||
eyebrow="Before you share details"
|
||||
title="Legal basics stay visible from the contact flow."
|
||||
description="Visitors should be able to inspect privacy and terms before they continue."
|
||||
/>
|
||||
<Cluster class="mt-6">
|
||||
<a
|
||||
href="/privacy"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||
>
|
||||
Privacy
|
||||
</a>
|
||||
<a
|
||||
href="/terms"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||
>
|
||||
Terms
|
||||
</a>
|
||||
<a
|
||||
href="/legal"
|
||||
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||
>
|
||||
Legal
|
||||
</a>
|
||||
</Cluster>
|
||||
<div class="mt-6">
|
||||
<RichText sections={contactLegalSections} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Move from a public introduction into the legal and product details that support a real evaluation."
|
||||
description="The contact route should never strand a serious buyer. The next path stays visible whether they need product context, privacy details, or website terms."
|
||||
primary={{ href: '/legal', label: 'Read the legal surface' }}
|
||||
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
@ -1,66 +1,65 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Callout from '@/components/content/Callout.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||
import LogoStrip from '@/components/sections/LogoStrip.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import {
|
||||
homeEcosystem,
|
||||
homeHero,
|
||||
homeMetrics,
|
||||
homePillars,
|
||||
homeProofBlocks,
|
||||
homeSeo,
|
||||
} from '@/content/pages/home';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="TenantPilot | Workspace Foundation"
|
||||
description="The first public TenantPilot website surface for workspace-safe Intune operations."
|
||||
>
|
||||
<main class="page-shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">TenantPilot</p>
|
||||
<h1>One public website, one stable platform, one clear workspace model.</h1>
|
||||
<p class="lede">
|
||||
TenantPilot keeps Intune change management auditable for operators while the public
|
||||
website stays fast, static, and operationally separate from the Laravel platform.
|
||||
</p>
|
||||
<PageShell currentPath="/" title={homeSeo.title} description={homeSeo.description}>
|
||||
<PageHero
|
||||
hero={homeHero}
|
||||
metrics={homeMetrics}
|
||||
calloutTitle="Governance of record for Microsoft tenant operations."
|
||||
calloutDescription="The public story positions TenantAtlas as a trust-first system for version truth, safer restore posture, drift visibility, evidence, and review support."
|
||||
/>
|
||||
|
||||
<div class="hero-actions">
|
||||
<a class="primary-action" href="#workspace-model">View the workspace model</a>
|
||||
<a class="secondary-action" href="#boundaries">Review the isolation rules</a>
|
||||
<LogoStrip
|
||||
eyebrow="Ecosystem fit"
|
||||
title="Built around the Microsoft tenant reality buyers already need to govern."
|
||||
items={homeEcosystem}
|
||||
/>
|
||||
|
||||
<FeatureGrid
|
||||
eyebrow="Product pillars"
|
||||
title="Explain the product in connected pillars, not isolated promises."
|
||||
description="Each section of the site should help a first-time visitor understand why backup, restore, findings, evidence, and reviews belong together."
|
||||
items={homePillars}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Public proof"
|
||||
title="A credible first reading should answer the buyer’s next two questions before they ask them."
|
||||
description="Why is this product category needed now, and why should anyone trust the story enough to continue?"
|
||||
/>
|
||||
<Grid cols="3">
|
||||
{homeProofBlocks.map((block) => <Callout content={block} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<section class="signal-grid" id="workspace-model" aria-label="Workspace foundations">
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Platform</p>
|
||||
<h2>Laravel stays in <code>apps/platform</code>.</h2>
|
||||
<p>
|
||||
Sail, Filament, Livewire, and deployment-sensitive runtime concerns remain
|
||||
platform-owned and unchanged.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Website</p>
|
||||
<h2>Astro lives independently in <code>apps/website</code>.</h2>
|
||||
<p>
|
||||
Public pages build statically, run without Laravel, and keep their own dev and
|
||||
build outputs.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Root</p>
|
||||
<h2>The repository root orchestrates without becoming an app.</h2>
|
||||
<p>
|
||||
Root scripts expose the official entry commands while app-local execution logic
|
||||
stays inside each app directory.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="boundary-panel" id="boundaries">
|
||||
<div>
|
||||
<p class="eyebrow">Isolation</p>
|
||||
<h2>Builds, ports, and ownership stay intentionally separate.</h2>
|
||||
</div>
|
||||
|
||||
<ul class="boundary-list">
|
||||
<li>Website dev defaults to port 4321 and supports explicit port overrides.</li>
|
||||
<li>Platform Docker, queues, and Filament assets stay under the existing Sail flow.</li>
|
||||
<li>No shared package layer, CMS, or extra app surface is introduced in this slice.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Move from first-glance clarity into the deeper product story."
|
||||
description="From the Home page, visitors should be able to inspect the product model, review trust framing, or reach the contact path without guessing where to go next."
|
||||
primary={{ href: '/product', label: 'See the product model' }}
|
||||
secondary={{ href: '/contact', label: 'Start the working session', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
55
apps/website/src/pages/integrations.astro
Normal file
55
apps/website/src/pages/integrations.astro
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
import IntegrationBadge from '@/components/content/IntegrationBadge.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import {
|
||||
integrationEntries,
|
||||
integrationRules,
|
||||
integrationsHero,
|
||||
integrationsSeo,
|
||||
} from '@/content/pages/integrations';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/integrations" title={integrationsSeo.title} description={integrationsSeo.description}>
|
||||
<PageHero
|
||||
hero={integrationsHero}
|
||||
calloutTitle="Real direction beats a longer wishlist."
|
||||
calloutDescription="The integrations page should reinforce where the product actually fits today and why those boundaries improve trust rather than limit it."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Current direction"
|
||||
title="Show the systems that shape the product workflow today."
|
||||
description="This page should stay focused on the contracts and ecosystems that matter to Microsoft tenant governance work now."
|
||||
/>
|
||||
<Grid cols="2">
|
||||
{integrationEntries.map((item) => <IntegrationBadge item={item} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<FeatureGrid
|
||||
eyebrow="Page rules"
|
||||
title="Use the integrations page to clarify scope, not to perform ambition."
|
||||
description="The public site becomes more credible when it names the real ecosystem fit and avoids presenting speculative adjacencies as if they were launch truth."
|
||||
items={integrationRules}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Turn ecosystem fit into a practical evaluation conversation."
|
||||
description="Once a buyer sees the Microsoft-centric fit, the next useful step is a working session about their current environment, governance needs, and rollout questions."
|
||||
primary={{ href: '/contact', label: 'Plan the working session' }}
|
||||
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
75
apps/website/src/pages/legal.astro
Normal file
75
apps/website/src/pages/legal.astro
Normal file
@ -0,0 +1,75 @@
|
||||
---
|
||||
import RichText from '@/components/content/RichText.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Card from '@/components/primitives/Card.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import { legalHero, legalNoticeSections, legalSeo } from '@/content/pages/legal';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/legal" title={legalSeo.title} description={legalSeo.description}>
|
||||
<PageHero
|
||||
hero={legalHero}
|
||||
calloutTitle="Public legal basics belong in one obvious place."
|
||||
calloutDescription="The legal hub keeps the conversion path honest by making privacy, terms, and notice routing easy to find before or during evaluation conversations."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Available now"
|
||||
title="Privacy and terms are published as standalone routes."
|
||||
description="The legal hub should work as an index and as the public home for launch-required legal notices."
|
||||
/>
|
||||
<Grid cols="3">
|
||||
<Card>
|
||||
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Privacy</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||
Review how the public contact path handles inquiry information and what this static website does not claim to process.
|
||||
</p>
|
||||
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="/privacy">
|
||||
Privacy
|
||||
</a>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Terms</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||
Read the website terms that explain the public-site scope and why marketing pages do not replace signed agreements.
|
||||
</p>
|
||||
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="/terms">
|
||||
Terms
|
||||
</a>
|
||||
</Card>
|
||||
<Card variant="accent">
|
||||
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Public legal notice</h3>
|
||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||
This hub also owns the future launch-ready operator identity and jurisdiction-specific disclosure section.
|
||||
</p>
|
||||
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="#public-legal-notice">
|
||||
Legal notice
|
||||
</a>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<Section id="public-legal-notice">
|
||||
<Container wide>
|
||||
<RichText sections={legalNoticeSections} />
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Continue"
|
||||
title="Return to the contact path once the legal basics are clear."
|
||||
description="The legal surface should support a qualified conversation, not interrupt it."
|
||||
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
31
apps/website/src/pages/privacy.astro
Normal file
31
apps/website/src/pages/privacy.astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
import RichText from '@/components/content/RichText.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import { privacyHero, privacySections, privacySeo } from '@/content/pages/privacy';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/privacy" title={privacySeo.title} description={privacySeo.description}>
|
||||
<PageHero
|
||||
hero={privacyHero}
|
||||
calloutTitle="Public-site privacy should stay narrow and readable."
|
||||
calloutDescription="The page explains the website and inquiry path clearly without pretending to be the product’s full data-processing documentation."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<RichText sections={privacySections} />
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Return to the product or contact flow after reviewing public-site privacy."
|
||||
description="Visitors should be able to move back into the evaluation path without losing context."
|
||||
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||
secondary={{ href: '/terms', label: 'Review website terms', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
61
apps/website/src/pages/product.astro
Normal file
61
apps/website/src/pages/product.astro
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
import Callout from '@/components/content/Callout.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import {
|
||||
productHero,
|
||||
productMetrics,
|
||||
productModelBlocks,
|
||||
productNarrative,
|
||||
productSeo,
|
||||
} from '@/content/pages/product';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/product" title={productSeo.title} description={productSeo.description}>
|
||||
<PageHero
|
||||
hero={productHero}
|
||||
metrics={productMetrics}
|
||||
calloutTitle="Connected governance model"
|
||||
calloutDescription="TenantAtlas connects present-state inventory, immutable snapshots, restore posture, drift, exceptions, and evidence so teams can explain what happened before they decide what to do next."
|
||||
/>
|
||||
|
||||
<FeatureGrid
|
||||
eyebrow="Connected governance model"
|
||||
title="Treat the product as one operating system for safer tenant change management."
|
||||
description="This page explains how the pieces fit together so visitors do not mistake the product for a loose collection of backup, reporting, and restore features."
|
||||
items={productModelBlocks}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Narrative"
|
||||
title="Explain the operator journey, not just the capabilities."
|
||||
description="The public product page should make it obvious how the product helps a team move from current-state understanding into reviewable action."
|
||||
/>
|
||||
<Grid cols="3">
|
||||
{productNarrative.map((block) => <Callout content={block} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Continue"
|
||||
title="Inspect whether the operating model fits your audience and workflow."
|
||||
description="The next useful questions are who the product is for, how trust claims stay grounded, and what a working conversation with the team should cover."
|
||||
primary={{ href: '/solutions', label: 'See audience fit' }}
|
||||
secondary={{
|
||||
href: '/contact',
|
||||
label: 'Talk through your current operating model',
|
||||
variant: 'secondary',
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
59
apps/website/src/pages/security-trust.astro
Normal file
59
apps/website/src/pages/security-trust.astro
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
import Callout from '@/components/content/Callout.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import TrustGrid from '@/components/sections/TrustGrid.astro';
|
||||
import {
|
||||
securityPrinciples,
|
||||
securityTrustHero,
|
||||
securityTrustNotes,
|
||||
securityTrustSeo,
|
||||
} from '@/content/pages/security-trust';
|
||||
---
|
||||
|
||||
<PageShell
|
||||
currentPath="/security-trust"
|
||||
title={securityTrustSeo.title}
|
||||
description={securityTrustSeo.description}
|
||||
>
|
||||
<PageHero
|
||||
hero={securityTrustHero}
|
||||
calloutTitle="Trust-first, not trust-theater."
|
||||
calloutDescription="The page should help technical buyers see the operator safeguards and the intentional limits of the launch story before they hear a sales pitch."
|
||||
/>
|
||||
|
||||
<TrustGrid
|
||||
eyebrow="Product posture"
|
||||
title="Operational trust starts with the way the product handles risky decisions."
|
||||
description="The trust page should explain the guardrails that matter to a serious buyer: previewability, attributable change history, evidence linkage, and restrained public claims."
|
||||
items={securityPrinciples}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Public messaging"
|
||||
title="Substantiated public posture"
|
||||
description="Keep the public trust story within the set of claims the team can support at launch."
|
||||
/>
|
||||
<Grid cols="3">
|
||||
{securityTrustNotes.map((note) => <Callout content={note} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Legal clarity and conversation path should stay reachable from the trust page."
|
||||
description="A buyer evaluating trust should be able to move directly to public legal information or a working discussion without friction."
|
||||
primary={{ href: '/legal', label: 'Read the legal surface' }}
|
||||
secondary={{ href: '/contact', label: 'Discuss trust requirements', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
20
apps/website/src/pages/sitemap.xml.ts
Normal file
20
apps/website/src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
import { sitemapEntries } from '@/lib/seo';
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const urls = sitemapEntries()
|
||||
.map((url) => ` <url><loc>${url}</loc></url>`)
|
||||
.join('\n');
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
});
|
||||
};
|
||||
55
apps/website/src/pages/solutions.astro
Normal file
55
apps/website/src/pages/solutions.astro
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
import AudienceRow from '@/components/content/AudienceRow.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Grid from '@/components/primitives/Grid.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import {
|
||||
solutionsAudiences,
|
||||
solutionsHero,
|
||||
solutionsSeo,
|
||||
solutionsSignals,
|
||||
} from '@/content/pages/solutions';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/solutions" title={solutionsSeo.title} description={solutionsSeo.description}>
|
||||
<PageHero
|
||||
hero={solutionsHero}
|
||||
calloutTitle="Audience-specific fit without product sprawl."
|
||||
calloutDescription="The public site can speak differently to MSP and enterprise visitors while staying anchored to the same product truth."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<div class="space-y-8">
|
||||
<SectionHeader
|
||||
eyebrow="Operating models"
|
||||
title="Separate the delivery context clearly."
|
||||
description="Visitors should be able to recognize themselves in the page quickly, without translating a generic story into their own workflow."
|
||||
/>
|
||||
<Grid cols="2">
|
||||
{solutionsAudiences.map((item) => <AudienceRow item={item} />)}
|
||||
</Grid>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<FeatureGrid
|
||||
eyebrow="Buying signal"
|
||||
title="Give the buyer a concrete reason to keep evaluating."
|
||||
description="The goal is not to decorate the page with vertical tags. The goal is to show why the product belongs in the operating model for that audience."
|
||||
items={solutionsSignals}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Continue"
|
||||
title="Inspect the ecosystem fit after you understand the audience fit."
|
||||
description="Once a visitor sees the product reflected in their operating model, the next useful question is how it fits the surrounding Microsoft tenant environment."
|
||||
primary={{ href: '/integrations', label: 'Review the ecosystem fit' }}
|
||||
secondary={{ href: '/contact', label: 'Talk through your evaluation path', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
31
apps/website/src/pages/terms.astro
Normal file
31
apps/website/src/pages/terms.astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
import RichText from '@/components/content/RichText.astro';
|
||||
import PageShell from '@/components/layout/PageShell.astro';
|
||||
import Container from '@/components/primitives/Container.astro';
|
||||
import Section from '@/components/primitives/Section.astro';
|
||||
import CTASection from '@/components/sections/CTASection.astro';
|
||||
import PageHero from '@/components/sections/PageHero.astro';
|
||||
import { termsHero, termsSections, termsSeo } from '@/content/pages/terms';
|
||||
---
|
||||
|
||||
<PageShell currentPath="/terms" title={termsSeo.title} description={termsSeo.description}>
|
||||
<PageHero
|
||||
hero={termsHero}
|
||||
calloutTitle="Public terms define the site, not a service contract."
|
||||
calloutDescription="The page keeps the public website honest about what it can explain and what still belongs in later commercial/legal paperwork."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Container wide>
|
||||
<RichText sections={termsSections} />
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<CTASection
|
||||
eyebrow="Next step"
|
||||
title="Move back into privacy or contact once the website terms are clear."
|
||||
description="The legal path should stay connected to the rest of the evaluation journey."
|
||||
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||
secondary={{ href: '/privacy', label: 'Review privacy', variant: 'secondary' }}
|
||||
/>
|
||||
</PageShell>
|
||||
@ -1,221 +1,157 @@
|
||||
@import "tailwindcss";
|
||||
@import "./tokens.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6efe5;
|
||||
--bg-accent: #fffdf9;
|
||||
--surface: rgba(255, 255, 255, 0.74);
|
||||
--surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--ink: #17120f;
|
||||
--muted: #66584d;
|
||||
--line: rgba(23, 18, 15, 0.12);
|
||||
--accent: #cc5f2c;
|
||||
--accent-deep: #8b3820;
|
||||
--shadow: 0 30px 80px rgba(103, 52, 33, 0.16);
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
--color-ink-900: #11243a;
|
||||
--color-ink-800: #233a53;
|
||||
--color-copy: #42556a;
|
||||
--color-line: rgba(17, 36, 58, 0.14);
|
||||
--color-panel: rgba(255, 255, 255, 0.82);
|
||||
--color-panel-strong: rgba(255, 255, 255, 0.95);
|
||||
--color-panel-soft: rgba(243, 247, 251, 0.86);
|
||||
--color-brand: #2f6fb7;
|
||||
--color-brand-soft: rgba(47, 111, 183, 0.12);
|
||||
--color-signal: #3b8b78;
|
||||
--color-warm: #af6d43;
|
||||
--shadow-panel: 0 24px 80px rgba(17, 36, 58, 0.12);
|
||||
--shadow-soft: 0 18px 48px rgba(17, 36, 58, 0.08);
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 201, 149, 0.55), transparent 34%),
|
||||
radial-gradient(circle at right 12% top 10%, rgba(255, 145, 96, 0.18), transparent 24%),
|
||||
linear-gradient(180deg, #fffaf3 0%, var(--bg) 58%, #efe3d5 100%);
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(92, 149, 215, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f6f3ee 0%, #edf2f7 56%, #f3f7fb 100%);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-ink-900);
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SFMono-Regular", "SF Mono", "IBM Plex Mono", monospace;
|
||||
font-size: 0.92em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1120px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 5rem;
|
||||
::selection {
|
||||
background: rgba(47, 111, 183, 0.18);
|
||||
color: var(--color-ink-900);
|
||||
}
|
||||
|
||||
.hero,
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow);
|
||||
:focus-visible {
|
||||
outline: 3px solid rgba(47, 111, 183, 0.32);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.surface-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: clamp(2rem, 4vw, 4.5rem);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 247, 239, 0.72)),
|
||||
linear-gradient(120deg, rgba(204, 95, 44, 0.08), rgba(255, 255, 255, 0));
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
.surface-shell::before {
|
||||
position: absolute;
|
||||
inset: auto -8rem -8rem auto;
|
||||
width: 18rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(204, 95, 44, 0.22), transparent 72%);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.signal-label {
|
||||
margin: 0 0 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.boundary-panel h2,
|
||||
.signal-card h2 {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 13ch;
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 46rem;
|
||||
margin: 1.5rem 0 0;
|
||||
font-size: clamp(1.05rem, 2vw, 1.35rem);
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.9rem 1.4rem;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
content: "";
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.65), transparent 16%),
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.7), transparent 28%);
|
||||
}
|
||||
|
||||
.surface-shell::after {
|
||||
position: absolute;
|
||||
inset: 1rem;
|
||||
z-index: -1;
|
||||
content: "";
|
||||
border: 1px solid rgba(17, 36, 58, 0.04);
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 40;
|
||||
transform: translateY(-200%);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink-900);
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
background-color 180ms ease;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: var(--ink);
|
||||
color: #fff7f1;
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
border: 1px solid rgba(23, 18, 15, 0.12);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
.glass-panel {
|
||||
background: linear-gradient(180deg, var(--color-panel-strong), var(--color-panel));
|
||||
box-shadow: var(--shadow-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.primary-action:hover,
|
||||
.secondary-action:hover {
|
||||
transform: translateY(-1px);
|
||||
.section-divider {
|
||||
border-top: 1px solid rgba(17, 36, 58, 0.08);
|
||||
}
|
||||
|
||||
.signal-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.4rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.legal-prose p {
|
||||
margin: 0;
|
||||
color: var(--color-copy);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.5rem;
|
||||
background: var(--surface);
|
||||
.legal-prose p + p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.signal-card h2 {
|
||||
font-size: clamp(1.55rem, 3vw, 2.1rem);
|
||||
}
|
||||
|
||||
.signal-card p:last-child,
|
||||
.boundary-list {
|
||||
.legal-prose ul {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--color-copy);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.boundary-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
background: var(--surface-strong);
|
||||
.legal-prose li + li {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.boundary-panel h2 {
|
||||
font-size: clamp(2rem, 4vw, 3.1rem);
|
||||
.motion-rise {
|
||||
animation: rise-in 520ms ease both;
|
||||
}
|
||||
|
||||
.boundary-list {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.boundary-list li + li {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.signal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
width: min(100% - 1.25rem, 1120px);
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.hero,
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
border-radius: 1.3rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
width: 100%;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/website/src/styles/tokens.css
Normal file
19
apps/website/src/styles/tokens.css
Normal file
@ -0,0 +1,19 @@
|
||||
@theme {
|
||||
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
|
||||
--font-display: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
|
||||
--color-shell-50: oklch(0.985 0.01 86);
|
||||
--color-shell-100: oklch(0.97 0.015 85);
|
||||
--color-shell-200: oklch(0.935 0.025 84);
|
||||
--color-shell-300: oklch(0.88 0.04 78);
|
||||
--color-shell-900: oklch(0.19 0.03 65);
|
||||
--color-shell-950: oklch(0.14 0.025 62);
|
||||
--color-brand-400: oklch(0.76 0.09 210);
|
||||
--color-brand-500: oklch(0.68 0.11 218);
|
||||
--color-brand-700: oklch(0.49 0.11 228);
|
||||
--color-signal-400: oklch(0.78 0.1 162);
|
||||
--color-signal-700: oklch(0.52 0.08 170);
|
||||
--color-warm-300: oklch(0.87 0.05 53);
|
||||
--color-warm-500: oklch(0.69 0.09 48);
|
||||
}
|
||||
105
apps/website/src/types/site.ts
Normal file
105
apps/website/src/types/site.ts
Normal file
@ -0,0 +1,105 @@
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
|
||||
export type PageRole =
|
||||
| 'home'
|
||||
| 'product'
|
||||
| 'solutions'
|
||||
| 'trust'
|
||||
| 'integrations'
|
||||
| 'contact'
|
||||
| 'legal';
|
||||
|
||||
export interface CtaLink {
|
||||
href: string;
|
||||
label: string;
|
||||
helper?: string;
|
||||
target?: '_blank' | '_self';
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export interface NavigationItem {
|
||||
href: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FooterNavigationGroup {
|
||||
items: NavigationItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SiteMetadata {
|
||||
siteDescription: string;
|
||||
siteName: string;
|
||||
siteTagline: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
export interface PageSeo {
|
||||
description: string;
|
||||
ogDescription?: string;
|
||||
ogTitle?: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HeroContent {
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
highlights?: string[];
|
||||
primaryCta: CtaLink;
|
||||
secondaryCta?: CtaLink;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface MetricItem {
|
||||
description: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FeatureItemContent {
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
href?: string;
|
||||
meta?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CalloutContent {
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
tone?: 'accent' | 'neutral' | 'subtle';
|
||||
}
|
||||
|
||||
export interface AudienceRowContent {
|
||||
audience: string;
|
||||
bullets: string[];
|
||||
cta?: CtaLink;
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TrustPrincipleContent {
|
||||
description: string;
|
||||
note?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface IntegrationEntry {
|
||||
category: string;
|
||||
name: string;
|
||||
note?: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface LogoStripItem {
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface LegalSection {
|
||||
body: string[];
|
||||
bullets?: string[];
|
||||
title: string;
|
||||
}
|
||||
57
apps/website/tests/smoke/contact-legal.spec.ts
Normal file
57
apps/website/tests/smoke/contact-legal.spec.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
expectFooterLinks,
|
||||
expectPrimaryNavigation,
|
||||
expectShell,
|
||||
openMobileNavigation,
|
||||
visitPage,
|
||||
} from './smoke-helpers';
|
||||
|
||||
const coreRoutes = ['/', '/product', '/solutions', '/security-trust', '/integrations', '/contact'] as const;
|
||||
|
||||
test('contact page qualifies the conversation and keeps legal links reachable', async ({ page }) => {
|
||||
await visitPage(page, '/contact');
|
||||
await expectShell(page, 'Start a qualified working session instead of a generic demo request.');
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Email the TenantAtlas team' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Privacy' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Terms' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('legal, privacy, and terms routes are published and linked', async ({ page }) => {
|
||||
await visitPage(page, '/legal');
|
||||
await expectShell(page, 'Legal access should stay one click away from the contact path.');
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Privacy' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Terms' }).first()).toBeVisible();
|
||||
|
||||
await visitPage(page, '/privacy');
|
||||
await expectShell(page, 'Public-site privacy overview for TenantAtlas inquiries.');
|
||||
|
||||
await visitPage(page, '/terms');
|
||||
await expectShell(page, 'Website terms for the public TenantAtlas surface.');
|
||||
});
|
||||
|
||||
test('core pages keep contact and legal paths within reach', async ({ page }) => {
|
||||
for (const path of coreRoutes) {
|
||||
await visitPage(page, path);
|
||||
await expectFooterLinks(page);
|
||||
|
||||
if (path !== '/contact') {
|
||||
await expect(page.locator('main a[href="/contact"]').first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('mobile navigation', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('mobile menu exposes the published contact and legal paths', async ({ page }) => {
|
||||
await visitPage(page, '/');
|
||||
await openMobileNavigation(page);
|
||||
await expect(page.getByRole('banner').getByRole('link', { name: /Contact/ }).first()).toBeVisible();
|
||||
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Privacy' })).toBeVisible();
|
||||
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Terms' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
39
apps/website/tests/smoke/home-product.spec.ts
Normal file
39
apps/website/tests/smoke/home-product.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
expectFooterLinks,
|
||||
expectPrimaryNavigation,
|
||||
expectShell,
|
||||
visitPage,
|
||||
} from './smoke-helpers';
|
||||
|
||||
test('home explains the product category and exposes the next step', async ({ page }) => {
|
||||
await visitPage(page, '/');
|
||||
await expectShell(page, /TenantAtlas/);
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Governance of record for Microsoft tenant operations.' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'See the product model' }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('main').getByRole('link', { name: 'Review the trust posture' }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('product explains the connected operating model instead of a loose feature list', async ({
|
||||
page,
|
||||
}) => {
|
||||
await visitPage(page, '/product');
|
||||
await expectShell(page, 'One operating model for change history, drift visibility, and review readiness.');
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(page.getByRole('heading', { name: 'Connected governance model' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'See audience fit' }).first()).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('main')
|
||||
.getByRole('link', { name: 'Talk through your current operating model' })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
45
apps/website/tests/smoke/smoke-helpers.ts
Normal file
45
apps/website/tests/smoke/smoke-helpers.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export const primaryNavigationLabels = [
|
||||
'Product',
|
||||
'Solutions',
|
||||
'Security & Trust',
|
||||
'Integrations',
|
||||
'Contact',
|
||||
] as const;
|
||||
|
||||
export const footerLabels = ['Legal', 'Privacy', 'Terms', 'Contact / Demo'] as const;
|
||||
|
||||
export async function visitPage(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await expect(page).toHaveURL(new RegExp(path === '/' ? '/?$' : `${path}$`));
|
||||
}
|
||||
|
||||
export async function expectShell(page: Page, heading: string | RegExp): Promise<void> {
|
||||
await expect(page.getByRole('banner')).toBeVisible();
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
await expect(page.getByRole('contentinfo')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: heading })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPrimaryNavigation(page: Page): Promise<void> {
|
||||
const header = page.getByRole('banner');
|
||||
|
||||
for (const label of primaryNavigationLabels) {
|
||||
await expect(header.getByRole('link', { name: label })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFooterLinks(page: Page): Promise<void> {
|
||||
for (const label of footerLabels) {
|
||||
await expect(page.getByRole('contentinfo').getByRole('link', { name: label })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openMobileNavigation(page: Page): Promise<void> {
|
||||
const menuTrigger = page.getByLabel('Open navigation menu');
|
||||
|
||||
if (await menuTrigger.isVisible()) {
|
||||
await menuTrigger.click();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
expectFooterLinks,
|
||||
expectPrimaryNavigation,
|
||||
expectShell,
|
||||
visitPage,
|
||||
} from './smoke-helpers';
|
||||
|
||||
test('solutions separates MSP and enterprise fit clearly', async ({ page }) => {
|
||||
await visitPage(page, '/solutions');
|
||||
await expectShell(page, /MSP|enterprise/i);
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(page.getByRole('heading', { name: 'MSP operating model' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Enterprise IT operating model' })).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Review the ecosystem fit' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('security and trust stays grounded in substantiated product posture', async ({ page }) => {
|
||||
await visitPage(page, '/security-trust');
|
||||
await expectShell(page, /trust posture|trust-first/i);
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(page.getByRole('heading', { name: 'Substantiated public posture' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Read the legal surface' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('integrations shows real ecosystem direction without wishlist claims', async ({ page }) => {
|
||||
await visitPage(page, '/integrations');
|
||||
await expectShell(page, /ecosystem fit|integrations/i);
|
||||
await expectPrimaryNavigation(page);
|
||||
await expectFooterLinks(page);
|
||||
await expect(page.getByText('Microsoft Graph')).toBeVisible();
|
||||
await expect(page.getByText('Entra ID')).toBeVisible();
|
||||
await expect(page.getByRole('main').getByRole('link', { name: 'Plan the working session' }).first()).toBeVisible();
|
||||
});
|
||||
18
apps/website/tsconfig.json
Normal file
18
apps/website/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@ -635,10 +635,11 @@ ### Key Commands
|
||||
```bash
|
||||
# Local dev
|
||||
corepack pnpm install # Install workspace JS dependencies
|
||||
corepack pnpm dev:platform # Start the platform stack
|
||||
corepack pnpm dev:platform # Start the platform stack + panel Vite watcher
|
||||
corepack pnpm dev:website # Start the website
|
||||
corepack pnpm dev # Start platform Vite + website together
|
||||
corepack pnpm build:website # Build the website
|
||||
cd apps/platform && ./vendor/bin/sail pnpm build # Build platform frontend
|
||||
corepack pnpm build:platform # Build platform frontend inside Sail
|
||||
|
||||
# Testing
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite
|
||||
|
||||
237
docs/strategy/website-working-contract.md
Normal file
237
docs/strategy/website-working-contract.md
Normal file
@ -0,0 +1,237 @@
|
||||
# Website Working Contract
|
||||
|
||||
> Guardrails for evolving `apps/website` as an independently evolvable track in the current repository.
|
||||
> This document is repo-truth-based and describes the currently verified state, not a speculative future architecture.
|
||||
|
||||
**Last reviewed**: 2026-04-18
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
`apps/website` is currently treated as an independently evolvable website track.
|
||||
|
||||
This contract is based on the current repository state:
|
||||
|
||||
- no verified runtime coupling to `apps/platform`
|
||||
- no verified shared DTO, enum, API, or auth contracts
|
||||
- current relevant coupling is primarily through root scripts, the pnpm workspace, the website package name, `WEBSITE_PORT`, and `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
|
||||
|
||||
Therefore, website work should be planned and implemented as technically independent by default unless a known minimal contract is being changed.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
This working contract applies to:
|
||||
|
||||
- `apps/website/**`
|
||||
- root files only insofar as they directly define or preserve website-facing workspace contracts
|
||||
|
||||
This document does not grant implicit approval to introduce new coupling to:
|
||||
|
||||
- `apps/platform`
|
||||
- shared runtime APIs
|
||||
- shared auth or session behavior
|
||||
- shared domain models
|
||||
- shared packages
|
||||
|
||||
Such coupling must be introduced deliberately and treated as a new explicit contract.
|
||||
|
||||
## 3. Current Repo-Based Statement
|
||||
|
||||
At the current verified repo state, `apps/website` is:
|
||||
|
||||
- a small static Astro application
|
||||
- built from local layout, local page, and local CSS files
|
||||
- without verified platform runtime dependency
|
||||
- without verified shared workspace packages
|
||||
- without verified platform API usage
|
||||
- without verified auth or session sharing
|
||||
|
||||
The current real technical coupling is concentrated in:
|
||||
|
||||
- `package.json`
|
||||
- `pnpm-workspace.yaml`
|
||||
- `apps/website/package.json`
|
||||
- `README.md`
|
||||
- `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
|
||||
|
||||
## 4. Freely Changeable Without Special Coordination
|
||||
|
||||
The following changes are currently treated as website-internal and are generally free to make as long as they do not create new outward-facing contracts.
|
||||
|
||||
### Content and Structure
|
||||
|
||||
- copy
|
||||
- headlines
|
||||
- messaging
|
||||
- CTA text
|
||||
- page sections
|
||||
- HTML structure inside the website
|
||||
|
||||
### UI and Frontend
|
||||
|
||||
- styling in `src/styles/global.css`
|
||||
- layout changes in `src/layouts/BaseLayout.astro`
|
||||
- additional components inside `apps/website`
|
||||
- additional pages under `src/pages`
|
||||
- additional layouts
|
||||
- additional local assets
|
||||
|
||||
### Astro-Local Evolution
|
||||
|
||||
- expanding page structure
|
||||
- refactoring inline markup into components
|
||||
- app-local reorganization inside `apps/website`
|
||||
- local SEO, meta, robots, and favicon updates
|
||||
|
||||
### Usually Non-Critical When Kept Local
|
||||
|
||||
- new website-only dependencies
|
||||
- app-local build or styling improvements
|
||||
- app-local content structures
|
||||
|
||||
## 5. Changes Requiring Coordination
|
||||
|
||||
The following changes must not happen silently because they touch real existing contracts in the current repository.
|
||||
|
||||
### Hard Minimal Contracts
|
||||
|
||||
- changing the package name `@tenantatlas/website`
|
||||
- changing the root script names for website flows
|
||||
- changing the `WEBSITE_PORT` convention
|
||||
- changing workspace membership via `apps/*`
|
||||
- moving `apps/website` out of the current workspace pattern
|
||||
|
||||
### Repo and Tooling Contracts
|
||||
|
||||
- changes that break `pnpm --filter @tenantatlas/website ...`
|
||||
- changes that break the root `dev:website` or `build:website` workflows
|
||||
- changes that break `PlatformWorkspaceCompatibilityTest.php`
|
||||
- changes to root scripts when the website is expected to remain reachable through root commands
|
||||
|
||||
### New Technical Couplings
|
||||
|
||||
- introducing API calls to `apps/platform`
|
||||
- introducing shared DTOs, enums, or payload contracts
|
||||
- introducing shared auth or session mechanics
|
||||
- introducing shared packages as a required dependency
|
||||
- introducing shared content or asset sources
|
||||
- introducing a shared runtime deployment model
|
||||
|
||||
## 6. Silent Assumptions That Are Not Allowed
|
||||
|
||||
For website work, the following assumptions are explicitly disallowed:
|
||||
|
||||
- monorepo membership does not automatically mean technical coupling
|
||||
- `apps/platform` is not automatically part of website frontend scope
|
||||
- Filament or Livewire changes are not automatically website-relevant
|
||||
- shared product domain does not automatically imply a shared technical contract
|
||||
- future integrations must not be pre-assumed when they do not exist in the repo
|
||||
|
||||
## 7. Rule for New Couplings
|
||||
|
||||
New coupling between website and platform is allowed only when it is introduced as an explicit new contract.
|
||||
|
||||
Before implementation, the following must be made clear:
|
||||
|
||||
- which coupling is being introduced
|
||||
- where it is defined in the repo
|
||||
- whether it is runtime, build, deploy, or convention coupling
|
||||
- what impact it has on independent evolution of `apps/website`
|
||||
- what stability guarantee is expected going forward
|
||||
|
||||
Without that explicit decision, the default rule is:
|
||||
|
||||
**Do not introduce a new coupling.**
|
||||
|
||||
## 8. Review Rule for Website PRs
|
||||
|
||||
A change in `apps/website` is uncritical by default when all of the following can be answered with `Yes`:
|
||||
|
||||
- Does the package name `@tenantatlas/website` remain unchanged?
|
||||
- Do the root scripts remain functionally compatible?
|
||||
- Does `WEBSITE_PORT` remain unchanged?
|
||||
- Does workspace membership through `apps/*` remain intact?
|
||||
- Is no new dependency on `apps/platform` introduced?
|
||||
- Is no new API, auth, DTO, or shared-package contract introduced?
|
||||
- Does the change stay entirely inside `apps/website`?
|
||||
|
||||
If any answer is `No`, the change is coordination-required.
|
||||
|
||||
## 9. Change Classes
|
||||
|
||||
### Class A — Free
|
||||
|
||||
Pure website change with no new outward-facing contract.
|
||||
|
||||
Examples:
|
||||
|
||||
- new landing page
|
||||
- refactor of `index.astro`
|
||||
- new components
|
||||
- CSS restructuring
|
||||
- local SEO changes
|
||||
|
||||
### Class B — Cautious
|
||||
|
||||
Change likely remains local but touches website build or dev behavior.
|
||||
|
||||
Examples:
|
||||
|
||||
- adjusting Astro configuration
|
||||
- adding website dependencies
|
||||
- larger structural changes in `src/`
|
||||
|
||||
Normal website review is usually sufficient.
|
||||
|
||||
### Class C — Coordination-Required
|
||||
|
||||
Change touches root, workspace, or test contracts, or introduces new coupling.
|
||||
|
||||
Examples:
|
||||
|
||||
- renaming the package
|
||||
- changing scripts
|
||||
- changing the port convention
|
||||
- changing the workspace pattern
|
||||
- integrating a platform API
|
||||
- introducing shared types
|
||||
|
||||
These changes require explicit coordination.
|
||||
|
||||
## 10. Operational Summary
|
||||
|
||||
Yes: `apps/website` may currently be developed as its own frontend and website track.
|
||||
|
||||
Condition: the known minimal contracts stay stable:
|
||||
|
||||
- `@tenantatlas/website`
|
||||
- root scripts
|
||||
- `WEBSITE_PORT`
|
||||
- `apps/*`
|
||||
- compatibility with `PlatformWorkspaceCompatibilityTest.php`
|
||||
|
||||
Not automatically part of website scope:
|
||||
|
||||
- changes in `apps/platform`
|
||||
|
||||
Introduce only deliberately:
|
||||
|
||||
- any new runtime, API, auth, shared-package, or DTO coupling
|
||||
|
||||
## 11. Evidence Anchors
|
||||
|
||||
This contract is grounded in the current repo state represented by these files:
|
||||
|
||||
- `apps/website/package.json`
|
||||
- `apps/website/astro.config.mjs`
|
||||
- `apps/website/src/pages/index.astro`
|
||||
- `apps/website/src/layouts/BaseLayout.astro`
|
||||
- `apps/website/src/styles/global.css`
|
||||
- `package.json`
|
||||
- `pnpm-workspace.yaml`
|
||||
- `README.md`
|
||||
- `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
|
||||
- `docker-compose.yml`
|
||||
|
||||
If these files change materially, this contract should be revalidated against the current checkout.
|
||||
@ -11,6 +11,11 @@ # Operator UX & Surface Standards
|
||||
|
||||
This document is normative for new operator-facing UI work and for major UI refactors.
|
||||
|
||||
It follows the constitution vocabulary for `Native Surface`, `Custom Surface`,
|
||||
`Shared Detail Micro-UI`, and shell/page/detail state ownership. This
|
||||
document complements the constitution and MUST NOT be used as a parallel
|
||||
rulebook that redefines those terms.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
TenantPilot is not a generic admin UI. It is an enterprise operator product for managed Microsoft tenant governance, backup, restore, monitoring, drift detection, and review workflows.
|
||||
@ -518,4 +523,4 @@ ## 16. Summary
|
||||
- explicit mutation scope
|
||||
- safe execution for dangerous actions
|
||||
- explicit workspace / tenant context
|
||||
- page-level audience and surface contracts
|
||||
- page-level audience and surface contracts
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user