Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
b9fdd847a6 docs: add spec 212 test authoring guardrails
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 49s
2026-04-18 12:00:17 +02:00
134 changed files with 1233 additions and 8640 deletions

View File

@ -3,9 +3,6 @@ apps/platform/node_modules/
apps/website/node_modules/ apps/website/node_modules/
apps/website/.astro/ apps/website/.astro/
apps/website/dist/ apps/website/dist/
apps/website/playwright-report/
apps/website/test-results/
apps/website/blob-report/
dist/ dist/
build/ build/
vendor/ vendor/

View File

@ -7,7 +7,6 @@ ## Relocation override
- Human-facing commands should use `cd apps/platform && ...`. - 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 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`. - 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. - `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. - 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.
@ -203,11 +202,6 @@ ## 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) - 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) - 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -242,9 +236,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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 START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -293,7 +293,6 @@ ## Application Structure & Architecture
## Workspace Commands ## 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`. - 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. - `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
## Frontend Bundling ## Frontend Bundling

3
.gitignore vendored
View File

@ -19,9 +19,6 @@
/apps/website/node_modules /apps/website/node_modules
/.pnpm-store /.pnpm-store
/apps/website/.astro /apps/website/.astro
/apps/website/playwright-report
/apps/website/test-results
/apps/website/blob-report
dist/ dist/
build/ build/
coverage/ coverage/

View File

@ -7,9 +7,6 @@ apps/platform/node_modules/
apps/website/node_modules/ apps/website/node_modules/
apps/website/.astro/ apps/website/.astro/
apps/website/dist/ apps/website/dist/
apps/website/playwright-report/
apps/website/test-results/
apps/website/blob-report/
vendor/ vendor/
apps/platform/vendor/ apps/platform/vendor/
*.log *.log

View File

@ -1,19 +1,33 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.5.0 -> 2.6.0 - Version change: 2.4.0 -> 2.5.0
- Modified principles: - Modified principles:
- UI surface taxonomy and review expectations: expanded with native - Test Suite Governance Must Live In The Delivery Workflow
vs custom classification, shared-detail host ownership, named (TEST-GOV-001): expanded into explicit test-impact disclosure,
anti-patterns, and shell/page/detail state ownership review lane discipline, minimal-fixture defaults, heavy-family visibility,
- Filament Native First / No Ad-hoc Styling (UI-FIL-001): expanded expensive-default bans, runtime-budget stewardship, review-stop
into explicit native-by-default, fake-native, shared-family, and rules, and escalation triggers
exception-boundary language - Governance review expectations: expanded to require test-purpose
classification, explicit runtime-cost review, and visible review
routine coverage in delivery artifacts
- Added sections: None - Added sections: None
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- None in this docs-only constitution slice; enforcement remains - ✅ .specify/memory/constitution.md
deferred to Spec 201 - ✅ .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)
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo - N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: None - Follow-up TODOs: None
@ -565,24 +579,6 @@ ##### Detail-first Operational Surface
- Destructive actions: detail header or grouped header actions only, always with confirmation. - Destructive actions: detail header or grouped header actions only, always with confirmation.
- Row click and explicit View/Inspect: not applicable. - 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) #### Action Surface Discipline (ACTSURF-001)
Goal: actions across all surfaces MUST make the next sensible operator Goal: actions across all surfaces MUST make the next sensible operator
@ -653,22 +649,6 @@ ##### Utility / System surfaces
- System or recovery status does not justify casual placement of - System or recovery status does not justify casual placement of
destructive or governance-changing actions. 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 ##### Action grouping and order
- Actions MUST be ordered by meaning, frequency, and risk. - Actions MUST be ordered by meaning, frequency, and risk.
@ -738,12 +718,7 @@ ##### Review gate
3. Is navigation cleanly separated from mutation? 3. Is navigation cleanly separated from mutation?
4. Are rare or risky actions removed from the primary plane? 4. Are rare or risky actions removed from the primary plane?
5. Is the hierarchy scanable in a few seconds? 5. Is the hierarchy scanable in a few seconds?
6. If this is a repeated detail family, what is shared core vs 6. Is this a real special type or just an unordered exception?
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. If those answers are not clear, the surface is non-conformant.
@ -762,11 +737,6 @@ ##### Primary inspect model
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models. - 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. - 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. - 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 ##### Row-click semantics
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces. - Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
@ -785,29 +755,6 @@ ##### Action hierarchy
- All other secondary actions MUST move to overflow. - 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. - 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
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces. - 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. - Destructive actions MUST live in overflow or the detail header.
@ -855,16 +802,6 @@ ##### Row density and scanability
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose. - 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. - 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 abstractions
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior. - 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. - Contract systems MUST NOT force placeholder UI.
@ -875,11 +812,6 @@ #### 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. 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 ##### Queue Decision Exception
- Allowed when per-item decision-making is the real queue work. - 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. - Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
@ -900,38 +832,6 @@ ##### Cross-panel Canonical Route Exception
- Allowed when only one canonical surface makes sense. - Allowed when only one canonical surface makes sense.
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful. - 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) #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
For every new or modified Filament Resource, RelationManager, or Page: For every new or modified Filament Resource, RelationManager, or Page:
@ -942,10 +842,6 @@ #### 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. - 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. - 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. - 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 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. - View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
- Create/Edit MUST provide consistent Save and Cancel UX. - Create/Edit MUST provide consistent Save and Cancel UX.
@ -954,9 +850,6 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut. - 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. - 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. - 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. - Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
- Empty `ActionGroup` and `BulkActionGroup` are forbidden. - 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. - Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
@ -971,12 +864,6 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix. - Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
- Every changed operator-facing surface MUST declare its broad - Every changed operator-facing surface MUST declare its broad
action-surface class and the one most likely next operator action. 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. - 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. - 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.
@ -1012,26 +899,6 @@ #### 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. - 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. - 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 Enforcement
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available. - 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. - A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
@ -1053,16 +920,6 @@ ##### Core rule
contextual references do not belong in the header; they belong directly contextual references do not belong in the header; they belong directly
at the affected field, status indicator, or relation. 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 ##### Maximum one primary visible header action
- Each record/detail page MUST expose at most one clearly prioritized - Each record/detail page MUST expose at most one clearly prioritized
@ -1165,9 +1022,6 @@ ##### Reviewer heuristics
- Pure navigation buttons in the header. - Pure navigation buttons in the header.
- Danger actions beside normal actions without clear separation. - Danger actions beside normal actions without clear separation.
- Rarely used administrative actions as visible standard buttons. - 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 - The header resembles an action stockpile instead of a focused
workflow entry point. workflow entry point.
@ -1266,12 +1120,8 @@ #### Enforcement Model (UI-REVIEW-001)
actions are ordered, canonical collection route, canonical detail actions are ordered, canonical collection route, canonical detail
route, scope signals and their exact meaning, canonical noun, route, scope signals and their exact meaning, canonical noun,
critical truth visible by default, workflow-vs-storage IA critical truth visible by default, workflow-vs-storage IA
justification, attention-load reduction, whether the surface is justification, attention-load reduction, and whether an exception
native, custom, or a shared detail family, what shared core vs host type is used.
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. - Missing any of those answers makes the spec incomplete.
PR review requirements PR review requirements
@ -1286,10 +1136,8 @@ #### Enforcement Model (UI-REVIEW-001)
promoted into primary navigation without justification, one case promoted into primary navigation without justification, one case
fragmented across multiple equal-rank pages, new automation that adds fragmented across multiple equal-rank pages, new automation that adds
attention surfaces without reducing operator work, noisy default attention surfaces without reducing operator work, noisy default
surfaces with no action/watch/reference hierarchy, `Filament Costume`, surfaces with no action/watch/reference hierarchy, or undocumented
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`, exceptions without dedicated tests.
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
undocumented exceptions without dedicated tests.
Guard tests Guard tests
- Repository guards SHOULD validate: declared surface type, declared - Repository guards SHOULD validate: declared surface type, declared
@ -1298,11 +1146,8 @@ #### Enforcement Model (UI-REVIEW-001)
presence of explicit Inspect on Queue / Review and History / Audit presence of explicit Inspect on Queue / Review and History / Audit
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
correct placement of destructive actions, truthful scope signals, correct placement of destructive actions, truthful scope signals,
stable canonical nouns across shells, absence of fake-native primary stable canonical nouns across shells, and dedicated tests for every
controls where metadata says the surface is native, bounded shared approved exception.
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 #### Immediate Retrofit Priorities
@ -1355,13 +1200,6 @@ #### Appendix A - One-page Condensed Constitution
- Destructive actions never sit openly beside inspect on standard lists. - Destructive actions never sit openly beside inspect on standard lists.
- Overflow is standardized per surface class and is never empty. - Overflow is standardized per surface class and is never empty.
- Bulk exists only when it is genuinely useful. - 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 - Navigation and mutation do not share equal visual weight without
explicit hierarchy. explicit hierarchy.
- Monitoring and workbench surfaces separate scope/context, selection, - Monitoring and workbench surfaces separate scope/context, selection,
@ -1390,14 +1228,10 @@ #### Appendix B - Feature Review Checklist
- Broad action-surface class is declared. - Broad action-surface class is declared.
- Detailed surface type is declared. - Detailed surface type is declared.
- The one most likely next operator action is explicit. - 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. - Primary inspect/open model is defined.
- Row-click rule is decided. - Row-click rule is decided.
- View/Inspect is correctly present or correctly forbidden. - View/Inspect is correctly present or correctly forbidden.
- Edit-as-inspect is used only when allowed. - 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. - Navigation and mutation are separated intentionally.
- Secondary actions are grouped correctly. - Secondary actions are grouped correctly.
- Destructive actions are placed correctly. - Destructive actions are placed correctly.
@ -1408,9 +1242,6 @@ #### Appendix B - Feature Review Checklist
- Canonical nouns stay consistent. - Canonical nouns stay consistent.
- Critical truth is visible. - Critical truth is visible.
- Scanability is preserved. - 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. - Exceptions are documented and tested.
- Header passes the 5-second scan rule (HDR-001). - Header passes the 5-second scan rule (HDR-001).
- No pure navigation in the header. - No pure navigation in the header.
@ -1430,11 +1261,6 @@ #### Appendix C - Red Flags for Future PRs
attention load. attention load.
- The surface creates more noise than priority. - The surface creates more noise than priority.
- Row click and View open the same destination. - 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. - A row becomes a control center.
- Archive or Delete sits openly beside View or Inspect on a standard list. - Archive or Delete sits openly beside View or Inspect on a standard list.
- More menus or bulk menus are empty. - More menus or bulk menus are empty.
@ -1448,10 +1274,6 @@ #### Appendix C - Red Flags for Future PRs
actions as one flat header rail. actions as one flat header rail.
- Critical health or operability truth is hidden by default. - Critical health or operability truth is hidden by default.
- A contract claims conformance while the rendered UI behaves differently. - 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. - Header has multiple equally weighted buttons without clear prioritization.
- "Open X" navigation links placed in the header instead of at the related field. - "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. - Governance-changing actions sit casually beside the primary action without friction.
@ -1472,36 +1294,14 @@ ### 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. - 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. - 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 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 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 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. - 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 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. - 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. - 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 Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts. - Update-safe, framework-native implementations take priority over page-local styling shortcuts.
@ -1513,19 +1313,13 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- native Filament components cannot express the required semantics, - native Filament components cannot express the required semantics,
- no suitable shared primitive exists, - no suitable shared primitive exists,
- and the deviation is justified briefly in code and in the governing spec or PR. - 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 - Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
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 Review and enforcement
- Every UI review MUST answer: - Every UI review MUST answer:
- which native Filament element or shared primitive was used, - which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken, - why an existing component was insufficient if an exception was taken,
- whether the surface is native, custom, or a shared detail family, - and whether any ad-hoc status or emphasis styling was introduced.
- 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. - 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) ### Incremental UI Standards Enforcement (UI-STD-001)
@ -1573,4 +1367,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18 **Version**: 2.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18

View File

@ -722,7 +722,6 @@ ## Application Structure & Architecture
## Frontend Bundling ## 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`. - 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. - `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. - 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.

View File

@ -560,7 +560,6 @@ ## Application Structure & Architecture
## Frontend Bundling ## 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`. - 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. - `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. - 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.

View File

@ -12,17 +12,14 @@ ## Multi-App Topology
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml` - 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` - `./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 ## Official Root Commands
- Install workspace-managed JavaScript dependencies: `corepack pnpm install` - Install workspace-managed JavaScript dependencies: `corepack pnpm install`
- Start the platform stack and Laravel panel Vite watcher: `corepack pnpm dev:platform` - Start the platform stack: `corepack pnpm dev:platform`
- Start the website dev server: `corepack pnpm dev:website` - Start the website dev server: `corepack pnpm dev:website`
- Start platform Vite + website together: `corepack pnpm dev` - Start platform + website together: `corepack pnpm dev`
- Build the website: `corepack pnpm build:website` - Build the website: `corepack pnpm build:website`
- Build platform frontend assets inside Sail: `corepack pnpm build:platform` - Build platform frontend assets: `corepack pnpm build:platform`
## App-Local Commands ## App-Local Commands
@ -32,7 +29,7 @@ ### Platform
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d` - Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate` - 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 migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
- 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 frontend watch/build inside Sail: `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` - Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
### Website ### Website

View File

@ -127,14 +127,12 @@ public function selectWorkspace(int $workspaceId): void
resourceId: (string) $workspace->getKey(), resourceId: (string) $workspace->getKey(),
); );
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ /** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class); $resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $resolver->resolve( $redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget); $this->redirect($redirectTarget);
} }
@ -172,14 +170,12 @@ public function createWorkspace(array $data): void
->success() ->success()
->send(); ->send();
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */ /** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class); $resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $resolver->resolve( $redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget); $this->redirect($redirectTarget);
} }

View File

@ -69,13 +69,15 @@ public function __invoke(Request $request): RedirectResponse
resourceId: (string) $workspace->getKey(), resourceId: (string) $workspace->getKey(),
); );
$intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) {
return redirect()->to($intendedUrl);
}
/** @var WorkspaceRedirectResolver $resolver */ /** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class); $resolver = app(WorkspaceRedirectResolver::class);
return redirect()->to($resolver->resolve( return redirect()->to($resolver->resolve($workspace, $user));
$workspace,
$user,
WorkspaceIntendedUrl::consume($request),
));
} }
} }

View File

@ -8,14 +8,18 @@
use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\AlertRuleResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory; use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Closure; use Closure;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Models\Contracts\HasTenants;
use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem; use Filament\Navigation\NavigationItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -29,7 +33,6 @@ class EnsureFilamentTenantSelected
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$panel = Filament::getCurrentOrDefaultPanel(); $panel = Filament::getCurrentOrDefaultPanel();
$resolvedContext = app(OperateHubShell::class)->resolvedContext($request);
$path = '/'.ltrim($request->path(), '/'); $path = '/'.ltrim($request->path(), '/');
@ -82,27 +85,75 @@ public function handle(Request $request, Closure $next): Response
return $next($request); 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 ( if (
! $resolvedContext->hasTenant() $tenantParameter === null
&& ! $this->hasCanonicalTenantSelection($request)
&& $this->adminPathRequiresTenantSelection($path) && $this->adminPathRequiresTenantSelection($path)
) { ) {
return redirect()->route('filament.admin.pages.choose-tenant'); return redirect()->route('filament.admin.pages.choose-tenant');
} }
if ($resolvedContext->pageCategory === TenantPageCategory::TenantBound && ! $resolvedContext->hasTenant()) { if ($tenantParameter !== null) {
$user = $request->user();
if ($user === null) {
return $next($request);
}
if (! $user instanceof HasTenants) {
abort(404); abort(404);
} }
if ( $tenant = $tenantParameter instanceof Tenant
$resolvedContext->hasTenant() ? $tenantParameter
&& ( : Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
$panel?->getId() === 'tenant'
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound) if (! $tenant instanceof Tenant) {
) abort(404);
) { }
Filament::setTenant($resolvedContext->tenant, true);
} elseif (! $resolvedContext->hasTenant()) { if ($workspaceId === null) {
Filament::setTenant(null, true); 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 ( if (
@ -240,4 +291,27 @@ private function adminPathRequiresTenantSelection(string $path): bool
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1; 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;
}
} }

View File

@ -7,9 +7,9 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory; use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -19,8 +19,6 @@
final class OperateHubShell final class OperateHubShell
{ {
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
public function __construct( public function __construct(
private WorkspaceContext $workspaceContext, private WorkspaceContext $workspaceContext,
private CapabilityResolver $capabilityResolver, private CapabilityResolver $capabilityResolver,
@ -85,7 +83,7 @@ public function headerActions(
public function activeEntitledTenant(?Request $request = null): ?Tenant public function activeEntitledTenant(?Request $request = null): ?Tenant
{ {
return $this->resolvedContext($request)->tenant; return $this->resolveActiveTenant($request);
} }
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
@ -93,162 +91,42 @@ public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
return $this->activeEntitledTenant($request); return $this->activeEntitledTenant($request);
} }
public function resolvedContext(?Request $request = null): ResolvedShellContext private function resolveActiveTenant(?Request $request = null): ?Tenant
{
$request ??= request();
if ($request instanceof Request) {
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
if ($cached instanceof ResolvedShellContext) {
return $cached;
}
}
$resolved = $this->buildResolvedContext($request);
if ($request instanceof Request) {
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
}
return $resolved;
}
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
{ {
$pageCategory = $this->pageCategory($request); $pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory); $routeTenant = $this->resolveRouteTenant($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
$workspaceSource = match (true) { if ($routeTenant instanceof Tenant) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace', return $routeTenant;
$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); $tenant = $this->resolveValidatedFilamentTenant($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) { if ($tenant instanceof Tenant) {
return new ResolvedShellContext( return $tenant;
workspace: $workspace, }
tenant: $tenant,
pageCategory: $pageCategory, if ($pageCategory === TenantPageCategory::TenantBound) {
state: 'tenant_scoped', return null;
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'filament_tenant',
);
} }
if ($pageCategory->allowsRememberedTenantRestore()) {
$rememberedTenant = $this->workspaceContext->rememberedTenant($request); $rememberedTenant = $this->workspaceContext->rememberedTenant($request);
if ($rememberedTenant instanceof Tenant) { if (! $rememberedTenant instanceof Tenant) {
return new ResolvedShellContext( return null;
workspace: $workspace,
tenant: $rememberedTenant,
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'remembered',
);
}
} }
if ($pageCategory->requiresExplicitTenant()) { if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
return new ResolvedShellContext( $this->workspaceContext->clearRememberedTenantContext($request);
workspace: $workspace,
tenant: null, return 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( return $rememberedTenant;
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'tenantless_workspace',
displayMode: 'tenantless',
workspaceSource: $workspaceSource,
recoveryReason: $recoveryReason,
);
} }
private function resolveValidatedFilamentTenant( private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
?Request $request = null, {
?TenantPageCategory $pageCategory = null,
?Workspace $workspace = null,
): ?Tenant {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -256,9 +134,8 @@ private function resolveValidatedFilamentTenant(
} }
$pageCategory ??= $this->pageCategory($request); $pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) { if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
return $tenant; return $tenant;
} }
@ -267,58 +144,19 @@ private function resolveValidatedFilamentTenant(
return null; return null;
} }
private function resolveValidatedRouteTenant( private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
?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(); $route = $request?->route();
$pageCategory ??= $this->pageCategory($request); $pageCategory ??= $this->pageCategory($request);
if ($route?->hasParameter('tenant')) { if ($route?->hasParameter('tenant')) {
return $this->resolveTenantIdentifier($route->parameter('tenant')); $tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null;
}
return $tenant;
} }
if ( if (
@ -329,35 +167,24 @@ private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPa
return null; return null;
} }
return $this->resolveTenantIdentifier($route->parameter('record')); $tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
}
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
{
$queryTenant = $request?->query('tenant');
if (filled($queryTenant)) {
return $this->resolveTenantIdentifier($queryTenant);
}
$queryTenantId = $request?->query('tenant_id');
if (filled($queryTenantId)) {
return $this->resolveTenantIdentifier($queryTenantId);
}
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null; return null;
} }
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant return $tenant;
{
if ($tenantIdentifier instanceof Tenant) {
return $tenantIdentifier;
} }
$tenantIdentifier = trim((string) $tenantIdentifier); private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
{
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
if ($tenantIdentifier === '') { $routeTenant = trim((string) $routeTenant);
if ($routeTenant === '') {
return null; return null;
} }
@ -365,58 +192,94 @@ private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
return Tenant::query() return Tenant::query()
->withTrashed() ->withTrashed()
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void { ->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $tenantIdentifier); $query->where('external_id', $routeTenant);
if (ctype_digit($tenantIdentifier)) { if (ctype_digit($routeTenant)) {
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier); $query->orWhere($tenantKeyColumn, (int) $routeTenant);
} }
}) })
->first(); ->first();
} }
private function tenantValidationReason( private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
Tenant $tenant, {
Workspace $workspace, $pageCategory ??= TenantPageCategory::fromRequest($request);
?Request $request = null,
?TenantPageCategory $pageCategory = null,
): ?string {
$pageCategory ??= $this->pageCategory($request);
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) { if ($pageCategory !== TenantPageCategory::TenantBound) {
return 'mismatched_workspace'; return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
} }
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(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
return 'not_member'; return false;
}
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
} }
if (! $this->capabilityResolver->isMember($user, $tenant)) { if (! $this->capabilityResolver->isMember($user, $tenant)) {
return 'not_member'; return false;
} }
$question = $pageCategory === TenantPageCategory::TenantBound return $this->tenantOperabilityService->outcomeFor(
? TenantOperabilityQuestion::TenantBoundViewability
: TenantOperabilityQuestion::AdministrativeDiscoverability;
$allowed = $this->tenantOperabilityService->outcomeFor(
tenant: $tenant, tenant: $tenant,
question: $question, question: $question,
actor: $user, actor: $user,
workspaceId: (int) $workspace->getKey(), workspaceId: $workspaceId,
lane: $pageCategory->lane(), lane: $lane,
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null, selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
)->allowed; )->allowed;
if ($allowed) {
return null;
}
return $pageCategory === TenantPageCategory::TenantBound
? 'inaccessible'
: 'not_operable';
} }
private function pageCategory(?Request $request = null): TenantPageCategory private function pageCategory(?Request $request = null): TenantPageCategory

View File

@ -1,40 +0,0 @@
<?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;
}
}

View File

@ -15,11 +15,9 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
{ {
return match ($pageCategory) { return match ($pageCategory) {
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow, TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
TenantPageCategory::TenantBound, TenantPageCategory::TenantBound => self::AdministrativeManagement,
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord, TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
TenantPageCategory::WorkspaceScoped, TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
}; };
} }
} }

View File

@ -9,9 +9,7 @@
enum TenantPageCategory: string enum TenantPageCategory: string
{ {
case WorkspaceScoped = 'workspace_scoped'; case WorkspaceScoped = 'workspace_scoped';
case WorkspaceChooserException = 'workspace_chooser_exception';
case TenantBound = 'tenant_bound'; case TenantBound = 'tenant_bound';
case TenantScopedEvidence = 'tenant_scoped_evidence';
case OnboardingWorkflow = 'onboarding_workflow'; case OnboardingWorkflow = 'onboarding_workflow';
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer'; case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
@ -28,21 +26,10 @@ public static function fromPath(string $path): self
{ {
$normalizedPath = '/'.ltrim($path, '/'); $normalizedPath = '/'.ltrim($path, '/');
if ($normalizedPath === '/admin/choose-workspace') {
return self::WorkspaceChooserException;
}
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) { if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
return self::CanonicalWorkspaceRecordViewer; 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) { if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
return self::OnboardingWorkflow; return self::OnboardingWorkflow;
} }
@ -57,41 +44,6 @@ public static function fromPath(string $path): self
return self::WorkspaceScoped; 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 public function lane(): TenantInteractionLane
{ {
return TenantInteractionLane::fromPageCategory($this); return TenantInteractionLane::fromPageCategory($this);

View File

@ -55,33 +55,6 @@ public function currentWorkspace(?Request $request = null): ?Workspace
return $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 public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{ {
$session = ($request && $request->hasSession()) ? $request->session() : session(); $session = ($request && $request->hasSession()) ? $request->session() : session();

View File

@ -7,7 +7,6 @@
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
@ -31,12 +30,8 @@ public function __construct(
* *
* Returns a fully qualified URL string. * Returns a fully qualified URL string.
*/ */
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string public function resolve(Workspace $workspace, User $user): string
{ {
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
return $intendedUrl;
}
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants() $selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
->where('workspace_id', $workspace->getKey()) ->where('workspace_id', $workspace->getKey())
->orderBy('name') ->orderBy('name')
@ -76,45 +71,4 @@ public function resolveFromId(int $workspaceId, User $user): string
return $this->resolve($workspace, $user); 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);
}
} }

View File

@ -4,14 +4,14 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */ /** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class); $workspaceContext = app(WorkspaceContext::class);
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
$workspace = $resolvedContext->workspace; $workspace = $workspaceContext->currentWorkspace(request());
$user = auth()->user(); $user = auth()->user();
@ -22,17 +22,29 @@
->values(); ->values();
} }
$currentTenant = $resolvedContext->tenant; $operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null; $currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : 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()); $lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenant instanceof Tenant || $lastTenantId !== null; $canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? 'No tenant selected'; $tenantLabel = $currentTenantName ?? 'All tenants';
$workspaceLabel = $workspace?->name ?? 'Choose workspace'; $workspaceLabel = $workspace?->name ?? 'Select workspace';
$hasActiveTenant = $currentTenantName !== null; $hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace $managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -40,7 +52,7 @@
$workspaceUrl = $workspace $workspaceUrl = $workspace
? route('admin.home') ? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin'); : ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; $tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
@endphp @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"> <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">
@ -76,18 +88,6 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<x-filament::dropdown.list> <x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }"> <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 --}} {{-- Workspace section --}}
<div class="space-y-1"> <div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
@ -128,11 +128,10 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
</div> </div>
</div> </div>
@if ($resolvedContext->pageCategory->requiresExplicitTenant() && $hasActiveTenant) @if ($isTenantScopedRoute)
<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"> <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" /> <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> <span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
<a <a
href="{{ ChooseTenant::getUrl(panel: 'admin') }}" 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" class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
@ -140,17 +139,6 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
Switch tenant Switch tenant
</a> </a>
</div> </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 @else
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">

View File

@ -1,41 +0,0 @@
<?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);
});

View File

@ -5,7 +5,6 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -64,36 +63,3 @@
->assertSee($workspace->name) ->assertSee($workspace->name)
->assertDontSee('name="workspace_id"', escape: false); ->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');
});

View File

@ -65,11 +65,13 @@
$this->actingAs($user) $this->actingAs($user)
->get('/admin/workspaces') ->get('/admin/workspaces')
->assertOk() ->assertOk()
->assertSee('Select workspace')
->assertSee('Select tenant')
->assertSee('Choose a workspace first.') ->assertSee('Choose a workspace first.')
->assertDontSee('Search tenants…'); ->assertDontSee('Search tenants…');
}); });
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void { it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']); $tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@ -80,10 +82,12 @@
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk() ->assertOk()
->assertSee($tenant->getFilamentName()) ->assertSee($tenant->getFilamentName())
->assertSee('Clear tenant scope'); ->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
}); });
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void { it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
$currentTenant = Tenant::factory()->create([ $currentTenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
'name' => 'YPTW2', 'name' => 'YPTW2',
@ -113,7 +117,9 @@
->assertOk() ->assertOk()
->assertSee($routedTenant->getFilamentName()) ->assertSee($routedTenant->getFilamentName())
->assertSee('Switch tenant') ->assertSee('Switch tenant')
->assertSee('Clear tenant scope'); ->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
}); });
it('filters the header tenant picker to tenants the user can access', function (): void { it('filters the header tenant picker to tenants the user can access', function (): void {

View File

@ -1,43 +0,0 @@
<?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');
});

View File

@ -87,43 +87,3 @@
]) ])
->assertRedirect('/admin/choose-workspace'); ->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();
});

View File

@ -1,34 +0,0 @@
<?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());
});

View File

@ -119,59 +119,3 @@
expect($url)->toBe($expectedRoute); 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));
});

View File

@ -45,7 +45,7 @@
$this->actingAs($user) $this->actingAs($user)
->get('/admin/workspaces') ->get('/admin/workspaces')
->assertOk() ->assertOk()
->assertSee('Choose workspace') ->assertSee('Select workspace')
->assertSee('Choose a workspace first.'); ->assertSee('Choose a workspace first.');
}); });

View File

@ -1,102 +0,0 @@
<?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');
});

View File

@ -11,12 +11,9 @@
expect(TenantPageCategory::fromPath($path))->toBe($expected); expect(TenantPageCategory::fromPath($path))->toBe($expected);
})->with([ })->with([
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped], 'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
'workspace chooser exception' => ['/admin/choose-workspace', TenantPageCategory::WorkspaceChooserException],
'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped], 'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped],
'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound], 'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound],
'tenant panel route' => ['/admin/t/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 index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow], 'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped], 'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped],

View File

@ -1,23 +1,9 @@
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example';
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
site: publicSiteUrl,
server: { server: {
host: true, host: true,
port: 4321, port: 4321,
}, },
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
}); });

View File

@ -9,18 +9,9 @@
"scripts": { "scripts": {
"dev": "astro dev --host 0.0.0.0 --port ${WEBSITE_PORT:-4321}", "dev": "astro dev --host 0.0.0.0 --port ${WEBSITE_PORT:-4321}",
"build": "astro build", "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": { "dependencies": {
"astro": "^6.0.0" "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"
} }
} }

View File

@ -1,29 +0,0 @@
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,
},
});

View File

@ -1,3 +1,2 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: /sitemap.xml

View File

@ -1,35 +0,0 @@
---
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>

View File

@ -1,23 +0,0 @@
---
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>

View File

@ -1,29 +0,0 @@
---
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>

View File

@ -1,18 +0,0 @@
---
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>

View File

@ -1,11 +0,0 @@
---
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>

View File

@ -1,32 +0,0 @@
---
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>

View File

@ -1,11 +0,0 @@
---
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>

View File

@ -1,19 +0,0 @@
---
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>

View File

@ -1,11 +0,0 @@
---
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>

View File

@ -1,18 +0,0 @@
---
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>

View File

@ -1,12 +0,0 @@
---
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>

View File

@ -1,29 +0,0 @@
---
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>

View File

@ -1,12 +0,0 @@
---
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>

View File

@ -1,16 +0,0 @@
---
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>

View File

@ -1,59 +0,0 @@
---
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>

View File

@ -1,105 +0,0 @@
---
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>

View File

@ -1,39 +0,0 @@
---
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>

View File

@ -1,25 +0,0 @@
---
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>

View File

@ -1,56 +0,0 @@
---
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>
)
}

View File

@ -1,23 +0,0 @@
---
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>

View File

@ -1,13 +0,0 @@
---
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>

View File

@ -1,14 +0,0 @@
---
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>

View File

@ -1,18 +0,0 @@
---
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>

View File

@ -1,32 +0,0 @@
---
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,
]}
/>

View File

@ -1,22 +0,0 @@
---
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>

View File

@ -1,27 +0,0 @@
---
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>

View File

@ -1,20 +0,0 @@
---
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>

View File

@ -1,31 +0,0 @@
---
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>

View File

@ -1,33 +0,0 @@
---
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>

View File

@ -1,28 +0,0 @@
---
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>

View File

@ -1,44 +0,0 @@
---
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>

View File

@ -1,96 +0,0 @@
---
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>

View File

@ -1,28 +0,0 @@
---
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>

View File

@ -1,24 +0,0 @@
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,
}),
};

View File

@ -1,65 +0,0 @@
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.',
],
},
];

View File

@ -1,145 +0,0 @@
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.',
},
];

View File

@ -1,86 +0,0 @@
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.',
},
];

View File

@ -1,44 +0,0 @@
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.',
],
},
];

View File

@ -1,60 +0,0 @@
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.',
],
},
];

View File

@ -1,110 +0,0 @@
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',
},
];

View File

@ -1,78 +0,0 @@
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',
},
];

View File

@ -1,90 +0,0 @@
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.',
},
];

View File

@ -1,60 +0,0 @@
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.',
],
},
];

View File

@ -1,26 +1,15 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { siteMetadata } from '@/lib/site';
interface Props { interface Props {
canonicalUrl?: string;
description?: string; description?: string;
openGraphDescription?: string;
openGraphTitle?: string;
robots?: string;
title?: string; title?: string;
} }
const { const {
canonicalUrl, description = 'TenantPilot keeps Intune governance observable, reviewable, and safe to operate.',
description = siteMetadata.siteDescription, title = 'TenantPilot',
robots = 'index,follow',
title = `${siteMetadata.siteName} | ${siteMetadata.siteTagline}`,
} = Astro.props; } = Astro.props;
const openGraphTitle = Astro.props.openGraphTitle ?? title;
const openGraphDescription = Astro.props.openGraphDescription ?? description;
--- ---
<!doctype html> <!doctype html>
@ -28,22 +17,11 @@ const openGraphDescription = Astro.props.openGraphDescription ?? description;
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="robots" content={robots} />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body> <body>
<a class="skip-link" href="#content">Skip to content</a>
<slot /> <slot />
</body> </body>
</html> </html>

View File

@ -1,27 +0,0 @@
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));
}

View File

@ -1,76 +0,0 @@
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}/`);
}

View File

@ -1,98 +0,0 @@
---
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>

View File

@ -1,65 +1,66 @@
--- ---
import Callout from '@/components/content/Callout.astro'; import BaseLayout from '../layouts/BaseLayout.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';
--- ---
<PageShell currentPath="/" title={homeSeo.title} description={homeSeo.description}> <BaseLayout
<PageHero title="TenantPilot | Workspace Foundation"
hero={homeHero} description="The first public TenantPilot website surface for workspace-safe Intune operations."
metrics={homeMetrics} >
calloutTitle="Governance of record for Microsoft tenant operations." <main class="page-shell">
calloutDescription="The public story positions TenantAtlas as a trust-first system for version truth, safer restore posture, drift visibility, evidence, and review support." <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>
<LogoStrip <div class="hero-actions">
eyebrow="Ecosystem fit" <a class="primary-action" href="#workspace-model">View the workspace model</a>
title="Built around the Microsoft tenant reality buyers already need to govern." <a class="secondary-action" href="#boundaries">Review the isolation rules</a>
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 buyers 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> </div>
</Container> </section>
</Section>
<CTASection <section class="signal-grid" id="workspace-model" aria-label="Workspace foundations">
eyebrow="Next step" <article class="signal-card">
title="Move from first-glance clarity into the deeper product story." <p class="signal-label">Platform</p>
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." <h2>Laravel stays in <code>apps/platform</code>.</h2>
primary={{ href: '/product', label: 'See the product model' }} <p>
secondary={{ href: '/contact', label: 'Start the working session', variant: 'secondary' }} Sail, Filament, Livewire, and deployment-sensitive runtime concerns remain
/> platform-owned and unchanged.
</PageShell> </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>

View File

@ -1,55 +0,0 @@
---
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>

View File

@ -1,75 +0,0 @@
---
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>

View File

@ -1,31 +0,0 @@
---
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 products 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>

View File

@ -1,61 +0,0 @@
---
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>

View File

@ -1,59 +0,0 @@
---
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>

View File

@ -1,20 +0,0 @@
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',
},
});
};

View File

@ -1,55 +0,0 @@
---
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>

View File

@ -1,31 +0,0 @@
---
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>

View File

@ -1,157 +1,221 @@
@import "tailwindcss";
@import "./tokens.css";
:root { :root {
color-scheme: light; color-scheme: light;
--color-ink-900: #11243a; --bg: #f6efe5;
--color-ink-800: #233a53; --bg-accent: #fffdf9;
--color-copy: #42556a; --surface: rgba(255, 255, 255, 0.74);
--color-line: rgba(17, 36, 58, 0.14); --surface-strong: rgba(255, 255, 255, 0.92);
--color-panel: rgba(255, 255, 255, 0.82); --ink: #17120f;
--color-panel-strong: rgba(255, 255, 255, 0.95); --muted: #66584d;
--color-panel-soft: rgba(243, 247, 251, 0.86); --line: rgba(23, 18, 15, 0.12);
--color-brand: #2f6fb7; --accent: #cc5f2c;
--color-brand-soft: rgba(47, 111, 183, 0.12); --accent-deep: #8b3820;
--color-signal: #3b8b78; --shadow: 0 30px 80px rgba(103, 52, 33, 0.16);
--color-warm: #af6d43; font-family: "Avenir Next", "Segoe UI", sans-serif;
--shadow-panel: 0 24px 80px rgba(17, 36, 58, 0.12); }
--shadow-soft: 0 18px 48px rgba(17, 36, 58, 0.08);
* {
box-sizing: border-box;
} }
html { html {
background: background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 32%), radial-gradient(circle at top left, rgba(255, 201, 149, 0.55), transparent 34%),
radial-gradient(circle at top right, rgba(92, 149, 215, 0.18), transparent 28%), radial-gradient(circle at right 12% top 10%, rgba(255, 145, 96, 0.18), transparent 24%),
linear-gradient(180deg, #f6f3ee 0%, #edf2f7 56%, #f3f7fb 100%); linear-gradient(180deg, #fffaf3 0%, var(--bg) 58%, #efe3d5 100%);
scroll-behavior: smooth;
} }
body { body {
min-height: 100vh;
margin: 0; margin: 0;
font-family: var(--font-sans); min-height: 100vh;
color: var(--color-ink-900); color: var(--ink);
text-rendering: optimizeLegibility;
}
*,
*::before,
*::after {
box-sizing: border-box;
} }
a { a {
color: inherit; color: inherit;
} }
img {
display: block;
max-width: 100%;
}
code { code {
font-family: var(--font-mono); font-family: "SFMono-Regular", "SF Mono", "IBM Plex Mono", monospace;
font-size: 0.92em;
} }
::selection { .page-shell {
background: rgba(47, 111, 183, 0.18); width: min(1120px, calc(100% - 2rem));
color: var(--color-ink-900); margin: 0 auto;
padding: 4.5rem 0 5rem;
} }
:focus-visible { .hero,
outline: 3px solid rgba(47, 111, 183, 0.32); .signal-card,
outline-offset: 4px; .boundary-panel {
}
main {
display: block;
}
.surface-shell {
position: relative;
isolation: isolate;
}
.surface-shell::before {
position: absolute;
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;
transition: transform 140ms ease;
}
.skip-link:focus {
transform: translateY(0);
}
.glass-panel {
background: linear-gradient(180deg, var(--color-panel-strong), var(--color-panel));
box-shadow: var(--shadow-panel);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
box-shadow: var(--shadow);
} }
.section-divider { .hero {
border-top: 1px solid rgba(17, 36, 58, 0.08); 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));
} }
.legal-prose p { .hero::after {
content: "";
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; margin: 0;
color: var(--color-copy); font-family: "Iowan Old Style", "Palatino Linotype", serif;
line-height: 1.8; line-height: 0.95;
} }
.legal-prose p + p { .hero h1 {
margin-top: 1rem; max-width: 13ch;
font-size: clamp(3rem, 8vw, 6rem);
} }
.legal-prose ul { .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;
border-radius: 999px;
text-decoration: none;
font-weight: 700;
transition:
transform 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease;
}
.primary-action {
background: var(--ink);
color: #fff7f1;
}
.secondary-action {
border: 1px solid rgba(23, 18, 15, 0.12);
background: rgba(255, 255, 255, 0.5);
}
.primary-action:hover,
.secondary-action:hover {
transform: translateY(-1px);
}
.signal-grid {
display: grid;
gap: 1.25rem;
margin-top: 1.4rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.signal-card,
.boundary-panel {
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1.5rem;
background: var(--surface);
}
.signal-card h2 {
font-size: clamp(1.55rem, 3vw, 2.1rem);
}
.signal-card p:last-child,
.boundary-list {
margin: 1rem 0 0; margin: 1rem 0 0;
padding-left: 1.1rem; color: var(--muted);
color: var(--color-copy); line-height: 1.7;
line-height: 1.75;
} }
.legal-prose li + li { .boundary-panel {
margin-top: 0.6rem; display: grid;
gap: 1rem;
margin-top: 1.25rem;
background: var(--surface-strong);
} }
.motion-rise { .boundary-panel h2 {
animation: rise-in 520ms ease both; font-size: clamp(2rem, 4vw, 3.1rem);
} }
@keyframes rise-in { .boundary-list {
from { padding-left: 1.2rem;
opacity: 0; }
transform: translateY(16px);
}
to { .boundary-list li + li {
opacity: 1; margin-top: 0.7rem;
transform: translateY(0); }
@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;
}
.hero,
.signal-card,
.boundary-panel {
border-radius: 1.3rem;
}
.hero {
padding: 1.4rem;
}
.hero-actions {
flex-direction: column;
}
.primary-action,
.secondary-action {
width: 100%;
} }
} }

View File

@ -1,19 +0,0 @@
@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);
}

View File

@ -1,105 +0,0 @@
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;
}

View File

@ -1,57 +0,0 @@
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();
});
});

View File

@ -1,39 +0,0 @@
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();
});

View File

@ -1,45 +0,0 @@
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();
}
}

View File

@ -1,37 +0,0 @@
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();
});

View File

@ -1,18 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
]
}

View File

@ -635,11 +635,10 @@ ### Key Commands
```bash ```bash
# Local dev # Local dev
corepack pnpm install # Install workspace JS dependencies corepack pnpm install # Install workspace JS dependencies
corepack pnpm dev:platform # Start the platform stack + panel Vite watcher corepack pnpm dev:platform # Start the platform stack
corepack pnpm dev:website # Start the website corepack pnpm dev:website # Start the website
corepack pnpm dev # Start platform Vite + website together
corepack pnpm build:website # Build the website corepack pnpm build:website # Build the website
corepack pnpm build:platform # Build platform frontend inside Sail cd apps/platform && ./vendor/bin/sail pnpm build # Build platform frontend
# Testing # Testing
cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite

View File

@ -1,237 +0,0 @@
# 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.

View File

@ -11,11 +11,6 @@ # Operator UX & Surface Standards
This document is normative for new operator-facing UI work and for major UI refactors. 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 ## 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. 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.

Some files were not shown because too many files have changed in this diff Show More