Compare commits
6 Commits
212-test-a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b21c3080 | |||
| 2d552c7ae8 | |||
| edf7646a18 | |||
| 445464afdc | |||
| 3bdd27f747 | |||
| ea9ef9cb38 |
@ -3,6 +3,9 @@ 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/
|
||||||
|
|||||||
14
.github/agents/copilot-instructions.md
vendored
14
.github/agents/copilot-instructions.md
vendored
@ -7,6 +7,7 @@ ## 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.
|
||||||
|
|
||||||
@ -202,6 +203,13 @@ ## 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)
|
||||||
|
- Markdown governance artifacts, JSON Schema plus logical OpenAPI planning contracts, and Bash-backed SpecKit scripts inside a PHP 8.4.15 / Laravel 12 / Filament v5 / Livewire v4 repository + `.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`, `docs/ui/operator-ux-surface-standards.md`, and Specs 196 through 200 (201-enforcement-review-guardrails)
|
||||||
|
- Repository-owned markdown and contract artifacts under `.specify/` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/201-enforcement-review-guardrails/`; no product database persistence (201-enforcement-review-guardrails)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -236,8 +244,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
- 201-enforcement-review-guardrails: Added Markdown governance artifacts, JSON Schema plus logical OpenAPI planning contracts, and Bash-backed SpecKit scripts inside a PHP 8.4.15 / Laravel 12 / Filament v5 / Livewire v4 repository + `.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`, `docs/ui/operator-ux-surface-standards.md`, and Specs 196 through 200
|
||||||
- 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
|
- 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
|
||||||
- 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
|
- 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`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@ -293,6 +293,7 @@ ## 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
3
.gitignore
vendored
@ -19,6 +19,9 @@
|
|||||||
/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/
|
||||||
|
|||||||
@ -7,6 +7,9 @@ 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
|
||||||
|
|||||||
@ -11,25 +11,46 @@ ## Important
|
|||||||
- `tasks.md`
|
- `tasks.md`
|
||||||
- `checklists/requirements.md`
|
- `checklists/requirements.md`
|
||||||
- Runtime-changing or test-affecting work MUST carry actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, `Browser`), affected lanes, fixture/default cost risks, heavy-family changes, escalation decisions, and minimal validation commands through the active `spec.md`, `plan.md`, and `tasks.md`.
|
- Runtime-changing or test-affecting work MUST carry actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, `Browser`), affected lanes, fixture/default cost risks, heavy-family changes, escalation decisions, and minimal validation commands through the active `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
- Review-oriented checklists MUST surface lane fit, hidden defaults, heavy-family visibility, and runtime-budget follow-up before merge; lane upkeep belongs to the feature, not to a later cleanup pass.
|
- Review-oriented checklists MUST surface nativity, shared-family boundaries, state-layer ownership, exception spread, proof depth, and close-out targeting before merge.
|
||||||
|
- `.specify/` is the operational workflow. `docs/ui/operator-ux-surface-standards.md` remains a rule/reference document, not a second checklist or review process.
|
||||||
|
|
||||||
|
## Author Entry Point
|
||||||
|
|
||||||
|
Use the active feature's `spec.md`, `plan.md`, and `tasks.md` in that order.
|
||||||
|
|
||||||
|
1. Fill the spec's UI / Surface Guardrail Impact section once. If there is no operator-facing surface change, write a concise `N/A` and do not invent extra prose downstream.
|
||||||
|
2. Turn that classification into plan-level handling modes, repository-signal treatment, required proof depth, and the named active feature PR close-out entry.
|
||||||
|
3. Carry the same terms into tasks so implementation, review, definition-of-done, exception documentation, and smoke coverage all point at the same guardrail decision.
|
||||||
|
|
||||||
## Review Entry Point
|
## Review Entry Point
|
||||||
|
|
||||||
Use the active feature's `spec.md`, `plan.md`, and `tasks.md` together with the generated checklist based on `.specify/templates/checklist-template.md`.
|
Use the active feature's `spec.md`, `plan.md`, and `tasks.md` together with the generated checklist based on `.specify/templates/checklist-template.md`.
|
||||||
|
|
||||||
1. Confirm the spec names the affected validation lane(s) or a deliberate `N/A`, the test family impact, setup-cost impact, reviewer handoff, and any escalation outcome.
|
1. Confirm the spec names the guardrail impact once: native/custom classification, shared-family relevance, state-layer ownership, exception need, and any low-impact `N/A` path.
|
||||||
2. Confirm the plan turns that into changed test types, narrowest proving commands, helper/default widening checks, and the note target for budget or trend drift.
|
2. Confirm the plan turns that into handling modes, repository-signal treatment, required test or smoke depth, and the named active feature PR close-out entry.
|
||||||
3. Apply the checklist and end with one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
3. Apply the checklist and end with both one review outcome class (`blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`) and one workflow outcome (`keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`).
|
||||||
|
4. If a guarded surface or exception remains in scope, ensure the active feature PR close-out entry records the final note rather than leaving the decision in scattered review comments.
|
||||||
|
|
||||||
## Low-Impact Rule
|
## Low-Impact Rule
|
||||||
|
|
||||||
- Docs-only or template-only work may answer `N/A` or `none`.
|
- Docs-only or template-only work may answer `N/A` or `none`.
|
||||||
- Do not force fake lane prose when no runtime or suite impact exists.
|
- Do not force fake surface, lane, or exception prose when no operator-facing change or runtime impact exists.
|
||||||
|
- Use the low-impact path to stay fast, not to hide a real guarded surface change.
|
||||||
|
|
||||||
## Escalation Rule
|
## Escalation Rule
|
||||||
|
|
||||||
|
- Use `blocker` when fake-native drift, hidden host drift, unresolved state ownership, or missing exception boundaries remain.
|
||||||
|
- Use `strong-warning` when the change can proceed only after the active workflow records the guardrail risk explicitly.
|
||||||
|
- Use `documentation-required-exception` when default rules are intentionally relaxed and the bounded exception record is the remaining requirement.
|
||||||
|
- Use `acceptable-special-case` only when the change already fits the declared guardrail contract.
|
||||||
- Use `document-in-feature` for contained cost or drift that belongs in the active feature.
|
- Use `document-in-feature` for contained cost or drift that belongs in the active feature.
|
||||||
- Use `follow-up-spec` only for recurring pain or structural lane or family changes.
|
- Use `follow-up-spec` only for recurring pain or structural lane or family changes.
|
||||||
- Use `reject-or-split` when hidden test cost or wrong-lane scope is still unresolved.
|
- Use `reject-or-split` when hidden test cost or wrong-lane scope is still unresolved.
|
||||||
|
|
||||||
|
## Close-Out Rule
|
||||||
|
|
||||||
|
- The named close-out target for guarded work is the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage`.
|
||||||
|
- That entry records the low-impact or representative scenario used, the outcome class, handling mode, workflow outcome, required tests or manual smoke, any exception boundary, duplicate-prompt notes, and any deferred automation.
|
||||||
|
- If the change is genuinely low-impact, record a concise `N/A` note rather than fabricating guardrail spread.
|
||||||
|
|
||||||
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.
|
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.
|
||||||
|
|||||||
@ -1,33 +1,19 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.4.0 -> 2.5.0
|
- Version change: 2.5.0 -> 2.6.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Test Suite Governance Must Live In The Delivery Workflow
|
- UI surface taxonomy and review expectations: expanded with native
|
||||||
(TEST-GOV-001): expanded into explicit test-impact disclosure,
|
vs custom classification, shared-detail host ownership, named
|
||||||
lane discipline, minimal-fixture defaults, heavy-family visibility,
|
anti-patterns, and shell/page/detail state ownership review
|
||||||
expensive-default bans, runtime-budget stewardship, review-stop
|
- Filament Native First / No Ad-hoc Styling (UI-FIL-001): expanded
|
||||||
rules, and escalation triggers
|
into explicit native-by-default, fake-native, shared-family, and
|
||||||
- Governance review expectations: expanded to require test-purpose
|
exception-boundary language
|
||||||
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:
|
||||||
- ✅ .specify/memory/constitution.md
|
- None in this docs-only constitution slice; enforcement remains
|
||||||
- ✅ .specify/templates/plan-template.md (lane-discipline and
|
deferred to Spec 201
|
||||||
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
|
||||||
@ -579,6 +565,24 @@ ##### 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
|
||||||
@ -649,6 +653,22 @@ ##### 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.
|
||||||
@ -718,7 +738,12 @@ ##### 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. Is this a real special type or just an unordered exception?
|
6. If this is a repeated detail family, what is shared core vs
|
||||||
|
host-owned variation?
|
||||||
|
7. Does one concern still have exactly one primary interaction model?
|
||||||
|
8. Which layer owns the relevant truth: shell, page, or detail?
|
||||||
|
9. Is any exception real, bounded, and named, or is it a hidden
|
||||||
|
exception?
|
||||||
|
|
||||||
If those answers are not clear, the surface is non-conformant.
|
If those answers are not clear, the surface is non-conformant.
|
||||||
|
|
||||||
@ -737,6 +762,11 @@ ##### Primary inspect model
|
|||||||
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
- 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.
|
||||||
@ -755,6 +785,29 @@ ##### 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.
|
||||||
@ -802,6 +855,16 @@ ##### 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.
|
||||||
@ -812,6 +875,11 @@ #### Exception Model (UI-EX-001)
|
|||||||
|
|
||||||
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
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.
|
||||||
@ -832,6 +900,38 @@ ##### 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:
|
||||||
@ -842,6 +942,10 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
- 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.
|
||||||
@ -850,6 +954,9 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
- 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.
|
||||||
@ -864,6 +971,12 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
- Every 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.
|
||||||
|
|
||||||
@ -899,6 +1012,26 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
|||||||
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
- 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.
|
||||||
@ -920,6 +1053,16 @@ ##### 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
|
||||||
@ -1022,6 +1165,9 @@ ##### 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.
|
||||||
|
|
||||||
@ -1120,8 +1266,12 @@ #### 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, and whether an exception
|
justification, attention-load reduction, whether the surface is
|
||||||
type is used.
|
native, custom, or a shared detail family, what shared core vs host
|
||||||
|
variation exists if relevant, which layer owns the relevant shell,
|
||||||
|
page, and detail truth, which requested/active/draft/inspect/
|
||||||
|
restorable roles exist, whether any fake-native or host-drift risk is
|
||||||
|
present, and whether an exception type is used.
|
||||||
- Missing any of those answers makes the spec incomplete.
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
PR review requirements
|
PR review requirements
|
||||||
@ -1136,8 +1286,10 @@ #### 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, or undocumented
|
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
||||||
exceptions without dedicated tests.
|
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||||
|
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||||
|
undocumented exceptions without dedicated tests.
|
||||||
|
|
||||||
Guard tests
|
Guard tests
|
||||||
- Repository guards SHOULD validate: declared surface type, declared
|
- Repository guards SHOULD validate: declared surface type, declared
|
||||||
@ -1146,8 +1298,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
presence of explicit Inspect on Queue / Review and History / Audit
|
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, and dedicated tests for every
|
stable canonical nouns across shells, absence of fake-native primary
|
||||||
approved exception.
|
controls where metadata says the surface is native, bounded shared
|
||||||
|
family contracts where metadata says a family is reused, explicit
|
||||||
|
state ownership where specs or metadata expose it, and dedicated
|
||||||
|
tests for every approved exception.
|
||||||
|
|
||||||
#### Immediate Retrofit Priorities
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
@ -1200,6 +1355,13 @@ #### 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,
|
||||||
@ -1228,10 +1390,14 @@ #### 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.
|
||||||
@ -1242,6 +1408,9 @@ #### 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.
|
||||||
@ -1261,6 +1430,11 @@ #### 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.
|
||||||
@ -1274,6 +1448,10 @@ #### 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.
|
||||||
@ -1294,14 +1472,36 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
- 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.
|
||||||
@ -1313,13 +1513,19 @@ ### 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 classes necessary, and MUST NOT invent a new page-local status language.
|
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||||
|
classes necessary, MUST NOT invent a new page-local status language,
|
||||||
|
and MUST say what remains standardized.
|
||||||
|
- `Hidden Exception` is forbidden. Historical accident or local
|
||||||
|
implementation convenience is not a valid substitute for UI-EX-001.
|
||||||
|
|
||||||
Review and enforcement
|
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,
|
||||||
- and whether any ad-hoc status or emphasis styling was introduced.
|
- whether the surface is native, custom, or a shared detail family,
|
||||||
|
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||||
|
contract was introduced.
|
||||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
- 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)
|
||||||
@ -1367,4 +1573,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.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18
|
**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18
|
||||||
|
|||||||
@ -5,39 +5,53 @@ # [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
|||||||
**Feature**: [Link to spec.md or relevant documentation]
|
**Feature**: [Link to spec.md or relevant documentation]
|
||||||
|
|
||||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||||
If the checklist covers runtime behavior or test-surface changes, use it to reach one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
If the checklist covers UI or surface work, use it to reach both one review
|
||||||
Low-impact docs-only or template-only work may mark runtime-only checks `N/A`, but should still leave one explicit outcome.
|
outcome class (`blocker`, `strong-warning`,
|
||||||
|
`documentation-required-exception`, or `acceptable-special-case`) and one
|
||||||
|
workflow outcome (`keep`, `split`, `document-in-feature`,
|
||||||
|
`follow-up-spec`, or `reject-or-split`). Low-impact docs-only or
|
||||||
|
template-only work may mark runtime-only checks `N/A`, but should still
|
||||||
|
leave one explicit workflow outcome and one note explaining why no
|
||||||
|
guardrail spread exists.
|
||||||
|
|
||||||
## Lane Fit
|
## Applicability And Low-Impact Gate
|
||||||
|
|
||||||
- [ ] CHK001 The chosen validation lane is the narrowest lane or lane mix that proves the change.
|
- [ ] CHK001 The change explicitly says whether an operator-facing surface or guardrail workflow surface is affected; low-impact `N/A` handling is used once and not contradicted elsewhere.
|
||||||
- [ ] CHK002 The test stays in the smallest honest family (`Unit`, `Feature`, `Heavy-Governance`, `Browser`) and does not hide broader purpose behind a narrow label.
|
- [ ] CHK002 The spec, plan, and task artifacts carry forward the same native/custom classification, shared-family relevance, state-layer ownership, and exception need without inventing second wording.
|
||||||
|
|
||||||
## Breadth And Cost
|
## Native, Shared-Family, And State Ownership
|
||||||
|
|
||||||
- [ ] CHK003 The changed or added test is no broader than the behavior it proves.
|
- [ ] CHK003 The surface remains native/shared-primitives first; fake-native controls, GET-form page-body interactions, and simple-overview replacements are not treated as harmless customization.
|
||||||
- [ ] CHK004 Any database, Livewire, Filament, or browser surface is justified over a narrower alternative.
|
- [ ] CHK004 Any shared-detail or shared-family surface keeps one shared contract, and any host variation is either folded back into that contract or explicitly bounded as an exception.
|
||||||
- [ ] CHK005 Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is explicit and locally justified.
|
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
||||||
|
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
||||||
|
|
||||||
## Validation And Drift
|
## Signals, Exceptions, And Test Depth
|
||||||
|
|
||||||
- [ ] CHK006 The minimal reviewer validation command is written explicitly and matches the declared lane.
|
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||||
- [ ] CHK007 Any material budget, baseline, trend, or runtime-drift note is recorded in the active spec or PR.
|
- [ ] CHK008 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||||
|
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
|
- [ ] CHK010 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Escalation Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK008 One explicit outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
- [ ] CHK009 New heavy families, new browser coverage, revived expensive defaults, or material lane-cost shifts are not left implicit.
|
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||||
|
- [ ] CHK013 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `keep`: current lane, family, and setup are justified.
|
- `blocker`: the change conflicts with the declared surface contract or guardrail and cannot proceed as proposed.
|
||||||
- `split`: scope is valid, but the test or helper spread should be narrowed before merge.
|
- `strong-warning`: the change may proceed only after the active workflow records the remaining guardrail risk explicitly.
|
||||||
- `document-in-feature`: the change is acceptable, but the cost or drift must be recorded in the active spec or PR.
|
- `documentation-required-exception`: the change is valid only once a bounded exception and close-out note exist.
|
||||||
- `follow-up-spec`: recurring pain or structural lane or family changes need dedicated governance work.
|
- `acceptable-special-case`: the change is legitimate without extra escalation beyond ordinary documentation.
|
||||||
- `reject-or-split`: hidden cost, wrong lane, or unjustified breadth blocks merge as proposed.
|
- `keep`: the current scope, guardrail handling, and proof depth are justified.
|
||||||
|
- `split`: the intent is valid, but the scope should narrow before merge.
|
||||||
|
- `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly.
|
||||||
|
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up.
|
||||||
|
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
|
||||||
- Check items off as completed: `[x]`
|
- Check items off as completed: `[x]`
|
||||||
- Add comments or findings inline
|
- Add comments or findings inline
|
||||||
- Link to relevant resources or documentation
|
- Link to relevant resources or documentation
|
||||||
- Items are numbered sequentially for easy reference
|
- Items are numbered sequentially for easy reference
|
||||||
- Reviewer-facing runtime checklists SHOULD stop merge when lane fit, hidden cost, heavy-family drift, or escalation handling is unclear.
|
- Reviewer-facing checklists SHOULD stop merge when nativity, shared-family boundaries, state ownership, exception spread, test depth, or escalation handling is unclear.
|
||||||
|
|||||||
@ -28,6 +28,21 @@ ## Technical Context
|
|||||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.**
|
||||||
|
|
||||||
|
- **Guardrail scope**: [no operator-facing surface change / changed surfaces / workflow-only guardrail change]
|
||||||
|
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
||||||
|
- **Shared-family relevance**: [none / list affected shared families]
|
||||||
|
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
||||||
|
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
||||||
|
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
||||||
|
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||||
|
- **Required tests or manual smoke**: [functional-core / state-contract / exception-fallback / manual-smoke / N/A]
|
||||||
|
- **Exception path and spread control**: [none / describe the named exception boundary]
|
||||||
|
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
@ -89,6 +104,11 @@ ## Constitution Check
|
|||||||
selection actions, navigation, and object actions; risky or rare
|
selection actions, navigation, and object actions; risky or rare
|
||||||
actions are grouped and ordered by meaning/frequency/risk; any special
|
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||||
type or workflow-hub exception is explicit and justified
|
type or workflow-hub exception is explicit and justified
|
||||||
|
- UI review workflow: native/custom classification, shared-family
|
||||||
|
relevance, state-layer ownership, repository-signal treatment,
|
||||||
|
exception path, and the active feature PR close-out entry stay
|
||||||
|
explicit without duplicating the same decision across spec, plan,
|
||||||
|
tasks, checklist, and close-out surfaces
|
||||||
|
|
||||||
## Test Governance Check
|
## Test Governance Check
|
||||||
|
|
||||||
@ -101,10 +121,12 @@ ## Test Governance Check
|
|||||||
- **Fixture / helper / factory / seed / context cost risks**: [none / describe]
|
- **Fixture / helper / factory / seed / context cost risks**: [none / describe]
|
||||||
- **Expensive defaults or shared helper growth introduced?**: [no / describe explicit opt-in path]
|
- **Expensive defaults or shared helper growth introduced?**: [no / describe explicit opt-in path]
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: [none / describe]
|
- **Heavy-family additions, promotions, or visibility changes**: [none / describe]
|
||||||
|
- **Surface-class relief / special coverage rule**: [standard-native relief / named special profile / N/A]
|
||||||
- **Closing validation and reviewer handoff**: [What must be re-run, what reviewers should verify, and what exact proof command they should rely on]
|
- **Closing validation and reviewer handoff**: [What must be re-run, what reviewers should verify, and what exact proof command they should rely on]
|
||||||
- **Budget / baseline / trend follow-up**: [none / describe]
|
- **Budget / baseline / trend follow-up**: [none / describe]
|
||||||
- **Review-stop questions**: [lane fit / breadth / hidden cost / heavy-family risk / escalation]
|
- **Review-stop questions**: [lane fit / breadth / hidden cost / heavy-family risk / escalation]
|
||||||
- **Escalation path**: [none / document-in-feature / follow-up-spec / reject-or-split]
|
- **Escalation path**: [none / document-in-feature / follow-up-spec / reject-or-split]
|
||||||
|
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
|
||||||
- **Why no dedicated follow-up spec is needed**: [Routine upkeep stays inside this feature unless recurring pain or structural lane changes justify a separate spec]
|
- **Why no dedicated follow-up spec is needed**: [Routine upkeep stays inside this feature unless recurring pain or structural lane changes justify a separate spec]
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
@ -35,11 +35,23 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
Use this section to classify UI and surface risk once. If the feature does
|
||||||
|
not change an operator-facing surface, write `N/A - no operator-facing surface
|
||||||
|
change` here and do not invent duplicate prose in the downstream surface tables.
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Tenant policies page | yes | Native Filament + shared primitives | none | page, detail | no | n/a |
|
||||||
|
| e.g. Docs-only change | no | N/A | none | none | no | `N/A - repository workflow only` |
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds or materially changes an operator-facing surface,
|
If this feature adds or materially changes an operator-facing surface,
|
||||||
fill out one row per affected surface. This role is orthogonal to the
|
fill out one row per affected surface. This role is orthogonal to the
|
||||||
Action Surface Class / Surface Type below.
|
Action Surface Class / Surface Type below. Reuse the exact surface names
|
||||||
|
and classifications from the UI / Surface Guardrail Impact section above.
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
@ -50,7 +62,8 @@ ## UI/UX Surface Classification *(mandatory when operator-facing surfaces are ch
|
|||||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
fill out one row per affected surface. Declare the broad Action Surface
|
fill out one row per affected surface. Declare the broad Action Surface
|
||||||
Class first, then the detailed Surface Type. Keep this table in sync
|
Class first, then the detailed Surface Type. Keep this table in sync
|
||||||
with the Decision-First Surface Role section above.
|
with the Decision-First Surface Role section above and avoid renaming the
|
||||||
|
same surface a second time.
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
@ -98,9 +111,12 @@ ## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|||||||
- **New or expanded test families**: [none / describe]
|
- **New or expanded test families**: [none / describe]
|
||||||
- **Fixture / helper cost impact**: [none / describe new defaults, factories, seeds, helpers, browser setup, provider setup, workspace or membership context, session state, etc.]
|
- **Fixture / helper cost impact**: [none / describe new defaults, factories, seeds, helpers, browser setup, provider setup, workspace or membership context, session state, etc.]
|
||||||
- **Heavy-family visibility / justification**: [none / explain any heavy-governance or browser addition and how it remains explicit in naming, lane choice, and review]
|
- **Heavy-family visibility / justification**: [none / explain any heavy-governance or browser addition and how it remains explicit in naming, lane choice, and review]
|
||||||
|
- **Special surface test profile**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||||
|
- **Standard-native relief or required special coverage**: [ordinary feature coverage only / describe required tests or smoke checks]
|
||||||
- **Reviewer handoff**: [What reviewers must confirm about lane fit, hidden cost, heavy-family visibility, and the exact proof command]
|
- **Reviewer handoff**: [What reviewers must confirm about lane fit, hidden cost, heavy-family visibility, and the exact proof command]
|
||||||
- **Budget / baseline / trend impact**: [none / expected drift + follow-up]
|
- **Budget / baseline / trend impact**: [none / expected drift + follow-up]
|
||||||
- **Escalation needed**: [none / document-in-feature / follow-up-spec / reject-or-split]
|
- **Escalation needed**: [none / document-in-feature / follow-up-spec / reject-or-split]
|
||||||
|
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
|
||||||
- **Planned validation commands**: [Exact minimal commands reviewers should run]
|
- **Planned validation commands**: [Exact minimal commands reviewers should run]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|||||||
@ -46,6 +46,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- using source/domain terms only where same-screen disambiguation is required,
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
- removing implementation-first wording from primary operator-facing copy.
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
|
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
||||||
|
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
||||||
|
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
||||||
|
- adding explicit review or definition-of-done work when a guarded surface class, repository signal, or exception path is involved,
|
||||||
|
- adding required tests or manual smoke for `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, or `exception-coded-surface`, OR recording `standard-native-filament` relief when no special contract exists,
|
||||||
|
- adding exception documentation and spread-control tasks whenever default surface rules are intentionally relaxed,
|
||||||
|
- recording the active feature PR close-out entry with guardrail class, exception status, required tests/manual smoke, low-impact `N/A` use, and any deferred automation.
|
||||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||||
- classifying each affected surface as Primary Decision, Secondary
|
- classifying each affected surface as Primary Decision, Secondary
|
||||||
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||||
@ -141,6 +148,7 @@ ## Test Governance Checklist
|
|||||||
- [ ] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
- [ ] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
|
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [ ] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||||
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||||
|
|
||||||
## Format: `[ID] [P?] [Story] Description`
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
@ -287,6 +295,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
|
- [ ] TXXX Record the active feature PR close-out entry with guardrail class, exception status, proof depth, and deferred automation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -722,6 +722,7 @@ ## 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.
|
||||||
|
|
||||||
|
|||||||
@ -560,6 +560,7 @@ ## 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.
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@ -12,14 +12,17 @@ ## Multi-App Topology
|
|||||||
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
|
- 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: `corepack pnpm dev:platform`
|
- Start the platform stack and Laravel panel Vite watcher: `corepack pnpm dev:platform`
|
||||||
- Start the website dev server: `corepack pnpm dev:website`
|
- Start the website dev server: `corepack pnpm dev:website`
|
||||||
- Start platform + website together: `corepack pnpm dev`
|
- Start platform Vite + website together: `corepack pnpm dev`
|
||||||
- Build the website: `corepack pnpm build:website`
|
- Build the website: `corepack pnpm build:website`
|
||||||
- Build platform frontend assets: `corepack pnpm build:platform`
|
- Build platform frontend assets inside Sail: `corepack pnpm build:platform`
|
||||||
|
|
||||||
## App-Local Commands
|
## App-Local Commands
|
||||||
|
|
||||||
@ -29,7 +32,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: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
- Run frontend watch/build inside Sail: `corepack pnpm dev:platform`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
### Website
|
### Website
|
||||||
|
|||||||
@ -127,12 +127,14 @@ 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 = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
$redirectTarget = $resolver->resolve(
|
||||||
|
$workspace,
|
||||||
|
$user,
|
||||||
|
WorkspaceIntendedUrl::consume(request()),
|
||||||
|
);
|
||||||
|
|
||||||
$this->redirect($redirectTarget);
|
$this->redirect($redirectTarget);
|
||||||
}
|
}
|
||||||
@ -170,12 +172,14 @@ 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 = $intendedUrl ?: $resolver->resolve($workspace, $user);
|
$redirectTarget = $resolver->resolve(
|
||||||
|
$workspace,
|
||||||
|
$user,
|
||||||
|
WorkspaceIntendedUrl::consume(request()),
|
||||||
|
);
|
||||||
|
|
||||||
$this->redirect($redirectTarget);
|
$this->redirect($redirectTarget);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,15 +69,13 @@ 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($workspace, $user));
|
return redirect()->to($resolver->resolve(
|
||||||
|
$workspace,
|
||||||
|
$user,
|
||||||
|
WorkspaceIntendedUrl::consume($request),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,18 +8,14 @@
|
|||||||
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;
|
||||||
@ -33,6 +29,7 @@ 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(), '/');
|
||||||
|
|
||||||
@ -85,75 +82,27 @@ 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 (
|
||||||
$tenantParameter === null
|
! $resolvedContext->hasTenant()
|
||||||
&& ! $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 ($tenantParameter !== null) {
|
if ($resolvedContext->pageCategory === TenantPageCategory::TenantBound && ! $resolvedContext->hasTenant()) {
|
||||||
$user = $request->user();
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user === null) {
|
if (
|
||||||
return $next($request);
|
$resolvedContext->hasTenant()
|
||||||
}
|
&& (
|
||||||
|
$panel?->getId() === 'tenant'
|
||||||
if (! $user instanceof HasTenants) {
|
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
|
||||||
abort(404);
|
)
|
||||||
}
|
) {
|
||||||
|
Filament::setTenant($resolvedContext->tenant, true);
|
||||||
$tenant = $tenantParameter instanceof Tenant
|
} elseif (! $resolvedContext->hasTenant()) {
|
||||||
? $tenantParameter
|
Filament::setTenant(null, true);
|
||||||
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->routeTenantIsAuthorized($tenant, $user, $workspaceId, $path)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isWorkspaceScopedPageWithTenant($path)) {
|
|
||||||
$this->configureNavigationForRequest($panel);
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
|
|
||||||
|
|
||||||
$this->configureNavigationForRequest($panel);
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -291,27 +240,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,6 +19,8 @@
|
|||||||
|
|
||||||
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,
|
||||||
@ -83,7 +85,7 @@ public function headerActions(
|
|||||||
|
|
||||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
||||||
{
|
{
|
||||||
return $this->resolveActiveTenant($request);
|
return $this->resolvedContext($request)->tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
||||||
@ -91,42 +93,162 @@ public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
|
|||||||
return $this->activeEntitledTenant($request);
|
return $this->activeEntitledTenant($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
public function resolvedContext(?Request $request = null): ResolvedShellContext
|
||||||
{
|
{
|
||||||
$pageCategory = $this->pageCategory($request);
|
$request ??= request();
|
||||||
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
|
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
if ($request instanceof Request) {
|
||||||
return $routeTenant;
|
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
|
||||||
|
|
||||||
|
if ($cached instanceof ResolvedShellContext) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory);
|
$resolved = $this->buildResolvedContext($request);
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($request instanceof Request) {
|
||||||
return $tenant;
|
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pageCategory === TenantPageCategory::TenantBound) {
|
return $resolved;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
|
||||||
|
|
||||||
if (! $rememberedTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
|
|
||||||
$this->workspaceContext->clearRememberedTenantContext($request);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rememberedTenant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
|
||||||
{
|
{
|
||||||
|
$pageCategory = $this->pageCategory($request);
|
||||||
|
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
||||||
|
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
||||||
|
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
|
||||||
|
|
||||||
|
$workspaceSource = match (true) {
|
||||||
|
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
||||||
|
$workspace instanceof Workspace && $routeTenantCandidate instanceof Tenant && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
|
||||||
|
default => 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: null,
|
||||||
|
tenant: null,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'missing_workspace',
|
||||||
|
displayMode: 'recovery',
|
||||||
|
recoveryAction: $pageCategory === TenantPageCategory::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
|
||||||
|
recoveryDestination: '/admin/choose-workspace',
|
||||||
|
recoveryReason: 'missing_workspace',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
|
||||||
|
|
||||||
|
if ($routeTenant['tenant'] instanceof Tenant) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $routeTenant['tenant'],
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'tenant_scoped',
|
||||||
|
displayMode: 'tenant_scoped',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
tenantSource: 'route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recoveryReason = $routeTenant['reason'];
|
||||||
|
|
||||||
|
if ($pageCategory === TenantPageCategory::TenantBound && $recoveryReason !== null) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'invalid_tenant',
|
||||||
|
displayMode: 'recovery',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
recoveryAction: 'abort_not_found',
|
||||||
|
recoveryReason: $recoveryReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
|
||||||
|
|
||||||
|
if ($queryHintTenant['tenant'] instanceof Tenant) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $queryHintTenant['tenant'],
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'tenant_scoped',
|
||||||
|
displayMode: 'tenant_scoped',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
tenantSource: 'query_hint',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recoveryReason ??= $queryHintTenant['reason'];
|
||||||
|
|
||||||
|
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'tenant_scoped',
|
||||||
|
displayMode: 'tenant_scoped',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
tenantSource: 'filament_tenant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageCategory->allowsRememberedTenantRestore()) {
|
||||||
|
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
||||||
|
|
||||||
|
if ($rememberedTenant instanceof Tenant) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $rememberedTenant,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'tenant_scoped',
|
||||||
|
displayMode: 'tenant_scoped',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
tenantSource: 'remembered',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageCategory->requiresExplicitTenant()) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'missing_tenant',
|
||||||
|
displayMode: 'recovery',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
recoveryAction: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
||||||
|
? 'redirect_evidence_overview'
|
||||||
|
: 'abort_not_found',
|
||||||
|
recoveryDestination: $pageCategory === TenantPageCategory::TenantScopedEvidence
|
||||||
|
? '/admin/evidence/overview'
|
||||||
|
: null,
|
||||||
|
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'tenantless_workspace',
|
||||||
|
displayMode: 'tenantless',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
recoveryReason: $recoveryReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValidatedFilamentTenant(
|
||||||
|
?Request $request = null,
|
||||||
|
?TenantPageCategory $pageCategory = null,
|
||||||
|
?Workspace $workspace = null,
|
||||||
|
): ?Tenant {
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -134,8 +256,9 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pageCategory ??= $this->pageCategory($request);
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
|
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
|
||||||
|
|
||||||
if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
|
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,19 +267,58 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
private function resolveValidatedRouteTenant(
|
||||||
|
?Tenant $tenant,
|
||||||
|
Workspace $workspace,
|
||||||
|
?Request $request = null,
|
||||||
|
?TenantPageCategory $pageCategory = null,
|
||||||
|
): array {
|
||||||
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['tenant' => null, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
|
||||||
|
|
||||||
|
if ($reason !== null) {
|
||||||
|
return ['tenant' => null, 'reason' => $reason];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['tenant' => $tenant, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValidatedQueryHintTenant(
|
||||||
|
?Request $request,
|
||||||
|
Workspace $workspace,
|
||||||
|
TenantPageCategory $pageCategory,
|
||||||
|
): array {
|
||||||
|
if (! $pageCategory->allowsQueryTenantHints()) {
|
||||||
|
return ['tenant' => null, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryTenant = $this->resolveQueryTenantHint($request);
|
||||||
|
|
||||||
|
if (! $queryTenant instanceof Tenant) {
|
||||||
|
return ['tenant' => null, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
|
||||||
|
|
||||||
|
if ($reason !== null) {
|
||||||
|
return ['tenant' => null, 'reason' => $reason];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['tenant' => $queryTenant, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||||
{
|
{
|
||||||
$route = $request?->route();
|
$route = $request?->route();
|
||||||
$pageCategory ??= $this->pageCategory($request);
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
|
|
||||||
if ($route?->hasParameter('tenant')) {
|
if ($route?->hasParameter('tenant')) {
|
||||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
|
return $this->resolveTenantIdentifier($route->parameter('tenant'));
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -167,24 +329,35 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
|
return $this->resolveTenantIdentifier($route->parameter('record'));
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
|
||||||
{
|
{
|
||||||
if ($routeTenant instanceof Tenant) {
|
$queryTenant = $request?->query('tenant');
|
||||||
return $routeTenant;
|
|
||||||
|
if (filled($queryTenant)) {
|
||||||
|
return $this->resolveTenantIdentifier($queryTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$routeTenant = trim((string) $routeTenant);
|
$queryTenantId = $request?->query('tenant_id');
|
||||||
|
|
||||||
if ($routeTenant === '') {
|
if (filled($queryTenantId)) {
|
||||||
|
return $this->resolveTenantIdentifier($queryTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
|
||||||
|
{
|
||||||
|
if ($tenantIdentifier instanceof Tenant) {
|
||||||
|
return $tenantIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = trim((string) $tenantIdentifier);
|
||||||
|
|
||||||
|
if ($tenantIdentifier === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,94 +365,58 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
|||||||
|
|
||||||
return Tenant::query()
|
return Tenant::query()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
|
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
|
||||||
$query->where('external_id', $routeTenant);
|
$query->where('external_id', $tenantIdentifier);
|
||||||
|
|
||||||
if (ctype_digit($routeTenant)) {
|
if (ctype_digit($tenantIdentifier)) {
|
||||||
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
|
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
private function tenantValidationReason(
|
||||||
{
|
Tenant $tenant,
|
||||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
Workspace $workspace,
|
||||||
|
?Request $request = null,
|
||||||
|
?TenantPageCategory $pageCategory = null,
|
||||||
|
): ?string {
|
||||||
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
|
|
||||||
if ($pageCategory !== TenantPageCategory::TenantBound) {
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
|
return 'mismatched_workspace';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->evaluateOutcome(
|
|
||||||
tenant: $tenant,
|
|
||||||
request: $request,
|
|
||||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isContextTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
|
|
||||||
{
|
|
||||||
$pageCategory ??= TenantPageCategory::fromRequest($request);
|
|
||||||
|
|
||||||
return match ($pageCategory) {
|
|
||||||
TenantPageCategory::TenantBound => $this->evaluateOutcome(
|
|
||||||
tenant: $tenant,
|
|
||||||
request: $request,
|
|
||||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
),
|
|
||||||
TenantPageCategory::CanonicalWorkspaceRecordViewer,
|
|
||||||
TenantPageCategory::OnboardingWorkflow,
|
|
||||||
TenantPageCategory::WorkspaceScoped => $this->evaluateOutcome(
|
|
||||||
tenant: $tenant,
|
|
||||||
request: $request,
|
|
||||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isRememberedTenantValid(Tenant $tenant, ?Request $request = null): bool
|
|
||||||
{
|
|
||||||
return $this->evaluateOutcome(
|
|
||||||
tenant: $tenant,
|
|
||||||
request: $request,
|
|
||||||
question: TenantOperabilityQuestion::RememberedContextValidity,
|
|
||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function evaluateOutcome(
|
|
||||||
Tenant $tenant,
|
|
||||||
?Request $request,
|
|
||||||
TenantOperabilityQuestion $question,
|
|
||||||
TenantInteractionLane $lane,
|
|
||||||
): bool {
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
return false;
|
return 'not_member';
|
||||||
}
|
|
||||||
|
|
||||||
$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 false;
|
return 'not_member';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenantOperabilityService->outcomeFor(
|
$question = $pageCategory === TenantPageCategory::TenantBound
|
||||||
|
? TenantOperabilityQuestion::TenantBoundViewability
|
||||||
|
: TenantOperabilityQuestion::AdministrativeDiscoverability;
|
||||||
|
|
||||||
|
$allowed = $this->tenantOperabilityService->outcomeFor(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
question: $question,
|
question: $question,
|
||||||
actor: $user,
|
actor: $user,
|
||||||
workspaceId: $workspaceId,
|
workspaceId: (int) $workspace->getKey(),
|
||||||
lane: $lane,
|
lane: $pageCategory->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
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OperateHub;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Tenants\TenantPageCategory;
|
||||||
|
|
||||||
|
final readonly class ResolvedShellContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?Workspace $workspace,
|
||||||
|
public ?Tenant $tenant,
|
||||||
|
public TenantPageCategory $pageCategory,
|
||||||
|
public string $state,
|
||||||
|
public string $displayMode,
|
||||||
|
public string $workspaceSource = 'none',
|
||||||
|
public string $tenantSource = 'none',
|
||||||
|
public string $recoveryAction = 'none',
|
||||||
|
public ?string $recoveryDestination = null,
|
||||||
|
public ?string $recoveryReason = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasWorkspace(): bool
|
||||||
|
{
|
||||||
|
return $this->workspace instanceof Workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTenant(): bool
|
||||||
|
{
|
||||||
|
return $this->tenant instanceof Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showsRecoveryNotice(): bool
|
||||||
|
{
|
||||||
|
return $this->displayMode === 'recovery' || $this->recoveryReason !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,9 +15,11 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
|
|||||||
{
|
{
|
||||||
return match ($pageCategory) {
|
return match ($pageCategory) {
|
||||||
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
|
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
|
||||||
TenantPageCategory::TenantBound => self::AdministrativeManagement,
|
TenantPageCategory::TenantBound,
|
||||||
|
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
|
||||||
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
||||||
TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
|
TenantPageCategory::WorkspaceScoped,
|
||||||
|
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -26,10 +28,21 @@ 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;
|
||||||
}
|
}
|
||||||
@ -44,6 +57,41 @@ 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);
|
||||||
|
|||||||
@ -55,6 +55,33 @@ 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();
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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;
|
||||||
@ -30,8 +31,12 @@ public function __construct(
|
|||||||
*
|
*
|
||||||
* Returns a fully qualified URL string.
|
* Returns a fully qualified URL string.
|
||||||
*/
|
*/
|
||||||
public function resolve(Workspace $workspace, User $user): string
|
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string
|
||||||
{
|
{
|
||||||
|
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
|
||||||
|
return $intendedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
|
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
|
||||||
->where('workspace_id', $workspace->getKey())
|
->where('workspace_id', $workspace->getKey())
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
@ -71,4 +76,45 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = $workspaceContext->currentWorkspace(request());
|
$workspace = $resolvedContext->workspace;
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -22,29 +22,17 @@
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
$operateHubShell = app(OperateHubShell::class);
|
$currentTenant = $resolvedContext->tenant;
|
||||||
$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 = $hasAnyFilamentTenantContext || $lastTenantId !== null;
|
$canClearTenantContext = $currentTenant instanceof Tenant || $lastTenantId !== null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$tenantLabel = $currentTenantName ?? 'All tenants';
|
$tenantLabel = $currentTenantName ?? 'No tenant selected';
|
||||||
$workspaceLabel = $workspace?->name ?? 'Select workspace';
|
$workspaceLabel = $workspace?->name ?? 'Choose 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])
|
||||||
@ -52,7 +40,7 @@
|
|||||||
$workspaceUrl = $workspace
|
$workspaceUrl = $workspace
|
||||||
? route('admin.home')
|
? route('admin.home')
|
||||||
: ChooseWorkspace::getUrl(panel: 'admin');
|
: ChooseWorkspace::getUrl(panel: 'admin');
|
||||||
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
|
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
|
||||||
@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">
|
||||||
@ -88,6 +76,18 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
|||||||
|
|
||||||
<x-filament::dropdown.list>
|
<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,16 +128,28 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($isTenantScopedRoute)
|
@if ($resolvedContext->pageCategory->requiresExplicitTenant() && $hasActiveTenant)
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
<div class="space-y-2">
|
||||||
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
||||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
|
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||||
<a
|
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName }}</span>
|
||||||
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
<a
|
||||||
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
||||||
>
|
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
Switch tenant
|
>
|
||||||
</a>
|
Switch tenant
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canClearTenantContext)
|
||||||
|
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||||
|
Clear tenant scope
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@if ($tenants->isEmpty())
|
@if ($tenants->isEmpty())
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows a recovery label when workspace-scoped surfaces render without an active workspace', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create(['name' => 'Recovery Tenant']);
|
||||||
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/workspaces')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Context unavailable')
|
||||||
|
->assertSee('Choose workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows explicit recovery wording when an invalid tenant hint is discarded on a workspace route', function (): void {
|
||||||
|
$validTenant = Tenant::factory()->active()->create(['name' => 'Valid Workspace Tenant']);
|
||||||
|
[$user, $validTenant] = createUserWithTenant(tenant: $validTenant, role: 'owner');
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
|
||||||
|
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id,
|
||||||
|
])
|
||||||
|
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Context unavailable')
|
||||||
|
->assertSee('No tenant selected')
|
||||||
|
->assertDontSee('Tenant scope: '.$foreignTenant->name);
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\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);
|
||||||
@ -63,3 +64,36 @@
|
|||||||
->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');
|
||||||
|
});
|
||||||
|
|||||||
@ -65,13 +65,11 @@
|
|||||||
$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('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
|
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
@ -82,12 +80,10 @@
|
|||||||
->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())
|
||||||
->assertDontSee('Search tenants…')
|
->assertSee('Clear tenant scope');
|
||||||
->assertDontSee('admin/select-tenant')
|
|
||||||
->assertDontSee('Clear tenant scope');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
|
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void {
|
||||||
$currentTenant = Tenant::factory()->create([
|
$currentTenant = Tenant::factory()->create([
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'name' => 'YPTW2',
|
'name' => 'YPTW2',
|
||||||
@ -117,9 +113,7 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee($routedTenant->getFilamentName())
|
->assertSee($routedTenant->getFilamentName())
|
||||||
->assertSee('Switch tenant')
|
->assertSee('Switch tenant')
|
||||||
->assertDontSee('Search tenants…')
|
->assertSee('Clear tenant scope');
|
||||||
->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 {
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Entry']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($tenant->workspace()->firstOrFail()->name)
|
||||||
|
->assertSee('Tenant Panel Entry')
|
||||||
|
->assertSee('Switch tenant')
|
||||||
|
->assertSee('Clear tenant scope')
|
||||||
|
->assertDontSee('Search tenants…')
|
||||||
|
->assertDontSee('admin/select-tenant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps workspace-scoped routes tenantless when a cross-workspace tenant hint is rejected', function (): void {
|
||||||
|
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Workspace Tenant']);
|
||||||
|
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Rejected Foreign Tenant']);
|
||||||
|
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No tenant selected')
|
||||||
|
->assertDontSee('Tenant scope: Rejected Foreign Tenant');
|
||||||
|
});
|
||||||
@ -87,3 +87,43 @@
|
|||||||
])
|
])
|
||||||
->assertRedirect('/admin/choose-workspace');
|
->assertRedirect('/admin/choose-workspace');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 404 when selecting a tenant from another workspace', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
|
||||||
|
createUserWithTenant(tenant: $foreignTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
|
->post(route('admin.select-tenant'), [
|
||||||
|
'tenant_id' => (int) $foreignTenant->getKey(),
|
||||||
|
])
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||||
|
->not->toHaveKey((string) $activeTenant->workspace_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when selecting a tenant the user cannot access', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->post(route('admin.select-tenant'), [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
])
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('ignores an intended tenant route that is not valid in the target workspace', function (): void {
|
||||||
|
$sourceTenant = Tenant::factory()->active()->create(['name' => 'Source Tenant']);
|
||||||
|
[$user, $sourceTenant] = createUserWithTenant(tenant: $sourceTenant, role: 'owner');
|
||||||
|
|
||||||
|
$targetWorkspaceTenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Target Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $targetWorkspaceTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$targetWorkspace = $targetWorkspaceTenant->workspace()->firstOrFail();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $sourceTenant->workspace_id,
|
||||||
|
WorkspaceContext::INTENDED_URL_SESSION_KEY => "/admin/t/{$sourceTenant->external_id}",
|
||||||
|
])
|
||||||
|
->post(route('admin.switch-workspace'), [
|
||||||
|
'workspace_id' => (int) $targetWorkspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $targetWorkspaceTenant));
|
||||||
|
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $targetWorkspace->getKey());
|
||||||
|
});
|
||||||
@ -119,3 +119,59 @@
|
|||||||
|
|
||||||
expect($url)->toBe($expectedRoute);
|
expect($url)->toBe($expectedRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a safe intended admin url that targets a tenant in the selected workspace', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]);
|
||||||
|
|
||||||
|
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
|
||||||
|
|
||||||
|
expect($url)->toBe($intendedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsafe intended admin url when its tenant hint targets another workspace', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]);
|
||||||
|
|
||||||
|
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
|
||||||
|
|
||||||
|
expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
|
});
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get('/admin/workspaces')
|
->get('/admin/workspaces')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Select workspace')
|
->assertSee('Choose workspace')
|
||||||
->assertSee('Choose a workspace first.');
|
->assertSee('Choose a workspace first.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('prefers a valid tenant query hint over remembered tenant state on workspace-scoped admin routes', function (): void {
|
||||||
|
$rememberedTenant = Tenant::factory()->active()->create(['name' => 'Remembered Tenant']);
|
||||||
|
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||||
|
|
||||||
|
$hintedTenant = Tenant::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||||
|
'name' => 'Hinted Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$workspaceId = (int) $rememberedTenant->workspace_id;
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||||
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||||
|
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]));
|
||||||
|
$request->setLaravelSession(app('session.store'));
|
||||||
|
$request->setUserResolver(static fn () => $user);
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->match($request);
|
||||||
|
$request->setRouteResolver(static fn () => $route);
|
||||||
|
|
||||||
|
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||||
|
|
||||||
|
expect($resolved->workspace?->getKey())->toBe($workspaceId)
|
||||||
|
->and($resolved->tenant?->is($hintedTenant))->toBeTrue()
|
||||||
|
->and($resolved->tenantSource)->toBe('query_hint')
|
||||||
|
->and($resolved->state)->toBe('tenant_scoped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a tenantless workspace state when a tenant query hint targets another workspace', function (): void {
|
||||||
|
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
|
||||||
|
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
|
||||||
|
|
||||||
|
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Tenant']);
|
||||||
|
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$workspaceId = (int) $workspaceTenant->workspace_id;
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||||
|
|
||||||
|
$request = Request::create(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]));
|
||||||
|
$request->setLaravelSession(app('session.store'));
|
||||||
|
$request->setUserResolver(static fn () => $user);
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->match($request);
|
||||||
|
$request->setRouteResolver(static fn () => $route);
|
||||||
|
|
||||||
|
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||||
|
|
||||||
|
expect($resolved->workspace?->getKey())->toBe($workspaceId)
|
||||||
|
->and($resolved->tenant)->toBeNull()
|
||||||
|
->and($resolved->state)->toBe('tenantless_workspace')
|
||||||
|
->and($resolved->recoveryReason)->toBe('mismatched_workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the routed tenant workspace when the tenant panel is entered without a selected workspace session', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Scope']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$request = Request::create("/admin/t/{$tenant->external_id}");
|
||||||
|
$request->setLaravelSession(app('session.store'));
|
||||||
|
$request->setUserResolver(static fn () => $user);
|
||||||
|
|
||||||
|
$route = app('router')->getRoutes()->match($request);
|
||||||
|
$request->setRouteResolver(static fn () => $route);
|
||||||
|
|
||||||
|
$resolved = app(OperateHubShell::class)->resolvedContext($request);
|
||||||
|
|
||||||
|
expect($resolved->workspace?->getKey())->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($resolved->tenant?->is($tenant))->toBeTrue()
|
||||||
|
->and($resolved->workspaceSource)->toBe('route')
|
||||||
|
->and($resolved->tenantSource)->toBe('route');
|
||||||
|
});
|
||||||
@ -11,9 +11,12 @@
|
|||||||
expect(TenantPageCategory::fromPath($path))->toBe($expected);
|
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],
|
||||||
|
|||||||
@ -1,9 +1,23 @@
|
|||||||
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,9 +9,18 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
apps/website/playwright.config.ts
Normal file
29
apps/website/playwright.config.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = Number(process.env.WEBSITE_PORT ?? '4321');
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/smoke',
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: `WEBSITE_PORT=${port} corepack pnpm dev`,
|
||||||
|
port,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,2 +1,3 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
Sitemap: /sitemap.xml
|
||||||
|
|||||||
35
apps/website/src/components/content/AudienceRow.astro
Normal file
35
apps/website/src/components/content/AudienceRow.astro
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
import SecondaryCTA from '@/components/content/SecondaryCTA.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { AudienceRowContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: AudienceRowContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card class="h-full">
|
||||||
|
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||||
|
{item.audience}
|
||||||
|
</p>
|
||||||
|
<h3 class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||||
|
<ul class="mt-5 space-y-3 p-0">
|
||||||
|
{
|
||||||
|
item.bullets.map((bullet) => (
|
||||||
|
<li class="list-none rounded-[1rem] bg-white/70 px-4 py-3 text-sm text-[var(--color-ink-800)]">
|
||||||
|
{bullet}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
{item.cta && (
|
||||||
|
<div class="mt-6">
|
||||||
|
<SecondaryCTA cta={item.cta} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
23
apps/website/src/components/content/Callout.astro
Normal file
23
apps/website/src/components/content/Callout.astro
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { CalloutContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: CalloutContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = Astro.props;
|
||||||
|
const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card variant={variant}>
|
||||||
|
{content.eyebrow && (
|
||||||
|
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||||
|
{content.eyebrow}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||||
|
{content.title}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{content.description}</p>
|
||||||
|
</Card>
|
||||||
29
apps/website/src/components/content/ContactPanel.astro
Normal file
29
apps/website/src/components/content/ContactPanel.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { CtaLink } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cta: CtaLink;
|
||||||
|
points: string[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cta, points, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card variant="accent">
|
||||||
|
<h3 class="m-0 text-3xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{title}</h3>
|
||||||
|
<ul class="mt-5 space-y-3 p-0">
|
||||||
|
{
|
||||||
|
points.map((point) => (
|
||||||
|
<li class="list-none rounded-[1rem] bg-white/72 px-4 py-3 text-sm text-[var(--color-ink-800)]">
|
||||||
|
{point}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div class="mt-6">
|
||||||
|
<Button href={cta.href} variant={cta.variant ?? 'primary'}>{cta.label}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
18
apps/website/src/components/content/DemoPrompt.astro
Normal file
18
apps/website/src/components/content/DemoPrompt.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||||
|
Conversation focus
|
||||||
|
</p>
|
||||||
|
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{title}</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{description}</p>
|
||||||
|
</Card>
|
||||||
11
apps/website/src/components/content/Eyebrow.astro
Normal file
11
apps/website/src/components/content/Eyebrow.astro
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class:list={['m-0 text-sm font-semibold uppercase tracking-[0.18em] text-[var(--color-brand)]', className]}>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
32
apps/website/src/components/content/FeatureItem.astro
Normal file
32
apps/website/src/components/content/FeatureItem.astro
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { FeatureItemContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: FeatureItemContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card class="h-full">
|
||||||
|
{item.eyebrow && (
|
||||||
|
<p class="m-0 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||||
|
{item.eyebrow}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 class="mt-4 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||||
|
{(item.meta || item.href) && (
|
||||||
|
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
{item.meta && <span class="text-[var(--color-brand)]">{item.meta}</span>}
|
||||||
|
{item.href && (
|
||||||
|
<a class="font-semibold text-[var(--color-ink-900)] underline decoration-[rgba(17,36,58,0.18)] underline-offset-4" href={item.href}>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
11
apps/website/src/components/content/Headline.astro
Normal file
11
apps/website/src/components/content/Headline.astro
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<h2 class:list={['m-0 font-[var(--font-display)] text-4xl leading-[0.98] tracking-[-0.03em] text-[var(--color-ink-900)] sm:text-5xl', className]}>
|
||||||
|
<slot />
|
||||||
|
</h2>
|
||||||
19
apps/website/src/components/content/IntegrationBadge.astro
Normal file
19
apps/website/src/components/content/IntegrationBadge.astro
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import Badge from '@/components/primitives/Badge.astro';
|
||||||
|
import type { IntegrationEntry } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: IntegrationEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="rounded-[1.1rem] border border-[rgba(17,36,58,0.08)] bg-white/78 px-4 py-3 shadow-[var(--shadow-soft)]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Badge tone="neutral">{item.category}</Badge>
|
||||||
|
<p class="m-0 text-base font-semibold text-[var(--color-ink-900)]">{item.name}</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 max-w-72 text-sm leading-6 text-[var(--color-copy)]">{item.summary}</p>
|
||||||
|
{item.note && <p class="mt-2 text-xs font-medium uppercase tracking-[0.14em] text-[var(--color-brand)]">{item.note}</p>}
|
||||||
|
</div>
|
||||||
11
apps/website/src/components/content/Lead.astro
Normal file
11
apps/website/src/components/content/Lead.astro
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class:list={['m-0 text-base leading-8 text-[var(--color-copy)] sm:text-lg', className]}>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
18
apps/website/src/components/content/Metric.astro
Normal file
18
apps/website/src/components/content/Metric.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { MetricItem } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: MetricItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card variant="subtle">
|
||||||
|
<p class="m-0 text-3xl font-semibold tracking-[-0.04em] text-[var(--color-ink-900)]">{item.value}</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-brand)]">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[var(--color-copy)]">{item.description}</p>
|
||||||
|
</Card>
|
||||||
12
apps/website/src/components/content/PrimaryCTA.astro
Normal file
12
apps/website/src/components/content/PrimaryCTA.astro
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import type { CtaLink } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cta: CtaLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cta } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Button href={cta.href} variant={cta.variant ?? 'primary'}>{cta.label}</Button>
|
||||||
29
apps/website/src/components/content/RichText.astro
Normal file
29
apps/website/src/components/content/RichText.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import type { LegalSection } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sections: LegalSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sections } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{
|
||||||
|
sections.map((section) => (
|
||||||
|
<section class="rounded-[1.5rem] border border-[rgba(17,36,58,0.08)] bg-white/72 p-6 shadow-[var(--shadow-soft)]">
|
||||||
|
<h2 class="m-0 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
<div class="legal-prose mt-4">
|
||||||
|
{section.body.map((paragraph) => <p>{paragraph}</p>)}
|
||||||
|
{section.bullets && section.bullets.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{section.bullets.map((bullet) => <li>{bullet}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
12
apps/website/src/components/content/SecondaryCTA.astro
Normal file
12
apps/website/src/components/content/SecondaryCTA.astro
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import type { CtaLink } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cta: CtaLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cta } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Button href={cta.href} variant={cta.variant ?? 'secondary'}>{cta.label}</Button>
|
||||||
16
apps/website/src/components/content/TrustPrincipleCard.astro
Normal file
16
apps/website/src/components/content/TrustPrincipleCard.astro
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import type { TrustPrincipleContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: TrustPrincipleContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card class="h-full">
|
||||||
|
<h3 class="m-0 text-2xl font-semibold tracking-[-0.03em] text-[var(--color-ink-900)]">{item.title}</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{item.description}</p>
|
||||||
|
{item.note && <p class="mt-4 text-sm font-medium text-[var(--color-brand)]">{item.note}</p>}
|
||||||
|
</Card>
|
||||||
59
apps/website/src/components/layout/Footer.astro
Normal file
59
apps/website/src/components/layout/Footer.astro
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import { contactCta, footerNavigationGroups, siteMetadata } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPath: _currentPath } = Astro.props;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="section-divider pt-10 sm:pt-12">
|
||||||
|
<Container wide>
|
||||||
|
<div class="grid gap-8 rounded-[2rem] bg-[rgba(255,255,255,0.58)] p-6 shadow-[var(--shadow-soft)] lg:grid-cols-[1.3fr,1fr] lg:p-8">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<p class="m-0 text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-brand)]">
|
||||||
|
{siteMetadata.siteName}
|
||||||
|
</p>
|
||||||
|
<h2 class="m-0 max-w-xl font-[var(--font-display)] text-3xl leading-[0.98] text-[var(--color-ink-900)] sm:text-4xl">
|
||||||
|
A calmer public surface for teams that need governance clarity before they need another dashboard.
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 max-w-xl text-base leading-7 text-[var(--color-copy)]">
|
||||||
|
TenantAtlas keeps product explanation, trust framing, and next-step guidance readable without hiding the product model behind hype or placeholders.
|
||||||
|
</p>
|
||||||
|
<Button href={contactCta.href} variant="primary" size="sm">{contactCta.label}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 sm:grid-cols-3">
|
||||||
|
{
|
||||||
|
footerNavigationGroups.map((group) => (
|
||||||
|
<div>
|
||||||
|
<p class="m-0 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-ink-900)]">
|
||||||
|
{group.title}
|
||||||
|
</p>
|
||||||
|
<ul class="mt-4 space-y-3 p-0 text-sm text-[var(--color-copy)]">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li class="list-none">
|
||||||
|
<a class="transition hover:text-[var(--color-brand)]" href={item.href}>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 py-6 text-sm text-[var(--color-copy)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="m-0">© {currentYear} {siteMetadata.siteName}. Public product site v0 foundation.</p>
|
||||||
|
<p class="m-0">
|
||||||
|
Built as a static Astro track with no platform auth, session, or API coupling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
105
apps/website/src/components/layout/Navbar.astro
Normal file
105
apps/website/src/components/layout/Navbar.astro
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import { contactCta, isActiveNavigationPath, primaryNavigation, siteMetadata } from '@/lib/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPath } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-30 pt-4 sm:pt-6">
|
||||||
|
<Container wide>
|
||||||
|
<div
|
||||||
|
class="glass-panel flex items-center justify-between gap-4 rounded-[1.75rem] border border-white/70 px-4 py-3 sm:px-5"
|
||||||
|
>
|
||||||
|
<a href="/" class="flex min-w-0 items-center gap-3 no-underline">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,var(--color-brand),#7fa6cf)] font-[var(--font-display)] text-lg text-white"
|
||||||
|
>
|
||||||
|
TA
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-ink-900)]">
|
||||||
|
{siteMetadata.siteName}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-sm text-[var(--color-copy)]">
|
||||||
|
{siteMetadata.siteTagline}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-1 lg:flex" aria-label="Primary">
|
||||||
|
{
|
||||||
|
primaryNavigation.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:list={[
|
||||||
|
'rounded-full px-4 py-2 text-sm font-medium transition',
|
||||||
|
isActiveNavigationPath(currentPath, item.href)
|
||||||
|
? 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]'
|
||||||
|
: 'text-[var(--color-ink-800)] hover:bg-white/70',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<Button href={contactCta.href} variant="secondary" size="sm">{contactCta.label}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="relative lg:hidden">
|
||||||
|
<summary
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
class="flex h-11 w-11 cursor-pointer list-none items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 text-[var(--color-ink-900)]"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open navigation menu</span>
|
||||||
|
<span class="flex flex-col gap-1">
|
||||||
|
<span class="block h-0.5 w-4 bg-current"></span>
|
||||||
|
<span class="block h-0.5 w-4 bg-current"></span>
|
||||||
|
<span class="block h-0.5 w-4 bg-current"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
class="glass-panel absolute right-0 top-[calc(100%+0.75rem)] w-[min(18rem,88vw)] rounded-[1.5rem] border border-white/80 p-3"
|
||||||
|
>
|
||||||
|
<nav class="flex flex-col gap-1" aria-label="Mobile primary">
|
||||||
|
{
|
||||||
|
primaryNavigation.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:list={[
|
||||||
|
'rounded-[1rem] px-4 py-3 text-sm',
|
||||||
|
isActiveNavigationPath(currentPath, item.href)
|
||||||
|
? 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]'
|
||||||
|
: 'text-[var(--color-ink-800)] hover:bg-white/75',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span class="block font-semibold">{item.label}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span class="mt-1 block text-xs text-[var(--color-copy)]">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<div class="mt-2 rounded-[1rem] bg-[rgba(47,111,183,0.08)] p-3">
|
||||||
|
<p class="m-0 text-sm font-semibold text-[var(--color-ink-900)]">
|
||||||
|
{contactCta.label}
|
||||||
|
</p>
|
||||||
|
{contactCta.helper && (
|
||||||
|
<p class="mt-1 text-sm text-[var(--color-copy)]">{contactCta.helper}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</header>
|
||||||
39
apps/website/src/components/layout/PageShell.astro
Normal file
39
apps/website/src/components/layout/PageShell.astro
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
import Footer from '@/components/layout/Footer.astro';
|
||||||
|
import Navbar from '@/components/layout/Navbar.astro';
|
||||||
|
import { resolveSeo } from '@/lib/seo';
|
||||||
|
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string;
|
||||||
|
description?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPath, description, title } = Astro.props;
|
||||||
|
const seo =
|
||||||
|
title && description
|
||||||
|
? resolveSeo({ description, path: currentPath, title })
|
||||||
|
: undefined;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
canonicalUrl={seo?.canonicalUrl}
|
||||||
|
openGraphTitle={seo?.ogTitle}
|
||||||
|
openGraphDescription={seo?.ogDescription}
|
||||||
|
robots={seo?.robots}
|
||||||
|
>
|
||||||
|
<div class="surface-shell min-h-screen">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[30rem] bg-[radial-gradient(circle_at_top,rgba(47,111,183,0.16),transparent_50%),radial-gradient(circle_at_top_right,rgba(59,139,120,0.14),transparent_28%)]"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Navbar currentPath={currentPath} />
|
||||||
|
<main id="content" class="pb-16 sm:pb-20">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<Footer currentPath={currentPath} />
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
25
apps/website/src/components/primitives/Badge.astro
Normal file
25
apps/website/src/components/primitives/Badge.astro
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
tone?: 'accent' | 'neutral' | 'signal' | 'warm';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '', tone = 'accent' } = Astro.props;
|
||||||
|
|
||||||
|
const toneClasses = {
|
||||||
|
accent: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]',
|
||||||
|
neutral: 'bg-white/75 text-[var(--color-ink-800)]',
|
||||||
|
signal: 'bg-[rgba(59,139,120,0.14)] text-[var(--color-signal)]',
|
||||||
|
warm: 'bg-[rgba(175,109,67,0.14)] text-[var(--color-warm)]',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
'inline-flex w-fit items-center rounded-full px-3 py-1 text-[0.72rem] font-semibold uppercase tracking-[0.18em]',
|
||||||
|
toneClasses[tone],
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
56
apps/website/src/components/primitives/Button.astro
Normal file
56
apps/website/src/components/primitives/Button.astro
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
import type { ButtonVariant } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ariaLabel?: string;
|
||||||
|
class?: string;
|
||||||
|
href?: string;
|
||||||
|
rel?: string;
|
||||||
|
size?: 'lg' | 'md' | 'sm';
|
||||||
|
target?: '_blank' | '_self';
|
||||||
|
type?: 'button' | 'reset' | 'submit';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
ariaLabel,
|
||||||
|
class: className = '',
|
||||||
|
href,
|
||||||
|
rel,
|
||||||
|
size = 'md',
|
||||||
|
target,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'primary',
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const baseClass =
|
||||||
|
'inline-flex items-center justify-center rounded-full border font-semibold tracking-[-0.01em] transition duration-150 focus-visible:outline-none';
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'min-h-10 px-4 text-sm',
|
||||||
|
md: 'min-h-11 px-5 text-sm sm:text-[0.95rem]',
|
||||||
|
lg: 'min-h-12 px-6 text-[0.95rem]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_18px_38px_rgba(17,36,58,0.16)] hover:bg-[var(--color-brand)]',
|
||||||
|
secondary:
|
||||||
|
'border-[color:var(--color-line)] bg-white/85 text-[var(--color-ink-900)] hover:border-[var(--color-ink-900)] hover:bg-white',
|
||||||
|
ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70',
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = [baseClass, sizeClasses[size], variantClasses[variant], className];
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
href ? (
|
||||||
|
<a href={href} target={target} rel={rel} aria-label={ariaLabel} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button type={type} aria-label={ariaLabel} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
apps/website/src/components/primitives/Card.astro
Normal file
23
apps/website/src/components/primitives/Card.astro
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: keyof HTMLElementTagNameMap;
|
||||||
|
class?: string;
|
||||||
|
variant?: 'accent' | 'default' | 'subtle';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'article', class: className = '', variant = 'default' } = Astro.props;
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'glass-panel border border-[color:var(--color-line)] bg-[var(--color-panel)]',
|
||||||
|
accent:
|
||||||
|
'border border-[rgba(47,111,183,0.18)] bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(238,245,252,0.94))] shadow-[var(--shadow-soft)]',
|
||||||
|
subtle:
|
||||||
|
'border border-[rgba(17,36,58,0.08)] bg-[linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.56))]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tag = as;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
13
apps/website/src/components/primitives/Cluster.astro
Normal file
13
apps/website/src/components/primitives/Cluster.astro
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: keyof HTMLElementTagNameMap;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'div', class: className = '' } = Astro.props;
|
||||||
|
const Tag = as;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag class:list={['flex flex-wrap items-center gap-3 sm:gap-4', className]}>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
14
apps/website/src/components/primitives/Container.astro
Normal file
14
apps/website/src/components/primitives/Container.astro
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: keyof HTMLElementTagNameMap;
|
||||||
|
class?: string;
|
||||||
|
wide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'div', class: className = '', wide = false } = Astro.props;
|
||||||
|
const Tag = as;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag class:list={['mx-auto w-full px-5 sm:px-6 lg:px-8', wide ? 'max-w-[80rem]' : 'max-w-6xl', className]}>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
18
apps/website/src/components/primitives/Grid.astro
Normal file
18
apps/website/src/components/primitives/Grid.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
cols?: '2' | '3' | '4';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '', cols = '3' } = Astro.props;
|
||||||
|
|
||||||
|
const colClasses = {
|
||||||
|
'2': 'grid-cols-1 md:grid-cols-2',
|
||||||
|
'3': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
|
||||||
|
'4': 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={['grid gap-5 lg:gap-6', colClasses[cols], className]}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
32
apps/website/src/components/primitives/Input.astro
Normal file
32
apps/website/src/components/primitives/Input.astro
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
name?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
class: className = '',
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
readonly = false,
|
||||||
|
type = 'text',
|
||||||
|
value,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
class:list={[
|
||||||
|
'min-h-12 w-full rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/90 px-4 text-[0.97rem] text-[var(--color-ink-900)] shadow-[var(--shadow-soft)] placeholder:text-[var(--color-copy)]/70',
|
||||||
|
readonly ? 'cursor-default' : '',
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
22
apps/website/src/components/primitives/Section.astro
Normal file
22
apps/website/src/components/primitives/Section.astro
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: keyof HTMLElementTagNameMap;
|
||||||
|
class?: string;
|
||||||
|
id?: string;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'section', class: className = '', id, muted = false } = Astro.props;
|
||||||
|
const Tag = as;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag
|
||||||
|
id={id}
|
||||||
|
class:list={[
|
||||||
|
'py-12 sm:py-16 lg:py-20',
|
||||||
|
muted ? 'bg-white/45' : '',
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
27
apps/website/src/components/primitives/SectionHeader.astro
Normal file
27
apps/website/src/components/primitives/SectionHeader.astro
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import Eyebrow from '@/components/content/Eyebrow.astro';
|
||||||
|
import Headline from '@/components/content/Headline.astro';
|
||||||
|
import Lead from '@/components/content/Lead.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
align?: 'center' | 'left';
|
||||||
|
class?: string;
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
align = 'left',
|
||||||
|
class: className = '',
|
||||||
|
description,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={['max-w-3xl', align === 'center' ? 'mx-auto text-center' : '', className]}>
|
||||||
|
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||||
|
<Headline>{title}</Headline>
|
||||||
|
{description && <Lead class="mt-4">{description}</Lead>}
|
||||||
|
</div>
|
||||||
20
apps/website/src/components/primitives/Stack.astro
Normal file
20
apps/website/src/components/primitives/Stack.astro
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: keyof HTMLElementTagNameMap;
|
||||||
|
class?: string;
|
||||||
|
gap?: 'lg' | 'md' | 'sm' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'div', class: className = '', gap = 'md' } = Astro.props;
|
||||||
|
const gapClasses = {
|
||||||
|
sm: 'flex flex-col gap-3',
|
||||||
|
md: 'flex flex-col gap-5',
|
||||||
|
lg: 'flex flex-col gap-7',
|
||||||
|
xl: 'flex flex-col gap-10',
|
||||||
|
};
|
||||||
|
const Tag = as;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag class:list={[gapClasses[gap], className]}>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
31
apps/website/src/components/primitives/Textarea.astro
Normal file
31
apps/website/src/components/primitives/Textarea.astro
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
name?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
class: className = '',
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
readonly = false,
|
||||||
|
rows = 5,
|
||||||
|
value,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name={name}
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
class:list={[
|
||||||
|
'min-h-32 w-full rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/90 px-4 py-3 text-[0.97rem] text-[var(--color-ink-900)] shadow-[var(--shadow-soft)] placeholder:text-[var(--color-copy)]/70',
|
||||||
|
readonly ? 'cursor-default' : '',
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>{value}</textarea>
|
||||||
33
apps/website/src/components/sections/CTASection.astro
Normal file
33
apps/website/src/components/sections/CTASection.astro
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
import SecondaryCTA from '@/components/content/SecondaryCTA.astro';
|
||||||
|
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { CtaLink } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
primary: CtaLink;
|
||||||
|
secondary?: CtaLink;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, eyebrow, primary, secondary, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<Card variant="accent" class="overflow-hidden">
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr] lg:items-end">
|
||||||
|
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row lg:justify-end">
|
||||||
|
<PrimaryCTA cta={primary} />
|
||||||
|
{secondary && <SecondaryCTA cta={secondary} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
28
apps/website/src/components/sections/FeatureGrid.astro
Normal file
28
apps/website/src/components/sections/FeatureGrid.astro
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
import FeatureItem from '@/components/content/FeatureItem.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { FeatureItemContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
items: FeatureItemContent[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, eyebrow, items, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||||
|
<Grid cols="3">
|
||||||
|
{items.map((item) => <FeatureItem item={item} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
44
apps/website/src/components/sections/LogoStrip.astro
Normal file
44
apps/website/src/components/sections/LogoStrip.astro
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
import IntegrationBadge from '@/components/content/IntegrationBadge.astro';
|
||||||
|
import Badge from '@/components/primitives/Badge.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import type { IntegrationEntry, LogoStripItem } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eyebrow?: string;
|
||||||
|
items: (IntegrationEntry | LogoStripItem)[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { eyebrow, items, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section class="pt-8 sm:pt-10">
|
||||||
|
<Container wide>
|
||||||
|
<div class="rounded-[1.8rem] border border-[rgba(17,36,58,0.08)] bg-white/55 px-5 py-6 shadow-[var(--shadow-soft)]">
|
||||||
|
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-3">
|
||||||
|
{eyebrow && <Badge tone="signal">{eyebrow}</Badge>}
|
||||||
|
<h2 class="m-0 max-w-2xl font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{
|
||||||
|
items.map((item) => (
|
||||||
|
<IntegrationBadge
|
||||||
|
item={{
|
||||||
|
category: 'category' in item ? item.category : 'Ecosystem',
|
||||||
|
name: item.label ?? item.name,
|
||||||
|
note: item.note,
|
||||||
|
summary: 'summary' in item ? item.summary : `${item.label} aligns with the launch story.`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
96
apps/website/src/components/sections/PageHero.astro
Normal file
96
apps/website/src/components/sections/PageHero.astro
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
import Badge from '@/components/primitives/Badge.astro';
|
||||||
|
import Button from '@/components/primitives/Button.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Cluster from '@/components/primitives/Cluster.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import type { HeroContent, MetricItem } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
calloutDescription?: string;
|
||||||
|
calloutTitle?: string;
|
||||||
|
hero: HeroContent;
|
||||||
|
metrics?: MetricItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="pt-8 sm:pt-10 lg:pt-14">
|
||||||
|
<Container wide>
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr]">
|
||||||
|
<Card class="motion-rise overflow-hidden">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Badge>{hero.eyebrow}</Badge>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="max-w-4xl font-[var(--font-display)] text-5xl leading-[0.93] tracking-[-0.04em] text-[var(--color-ink-900)] sm:text-6xl lg:text-7xl">
|
||||||
|
{hero.title}
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-3xl text-lg leading-8 text-[var(--color-copy)] sm:text-xl">
|
||||||
|
{hero.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{(hero.primaryCta || hero.secondaryCta) && (
|
||||||
|
<Cluster>
|
||||||
|
<Button href={hero.primaryCta.href} variant={hero.primaryCta.variant ?? 'primary'}>
|
||||||
|
{hero.primaryCta.label}
|
||||||
|
</Button>
|
||||||
|
{hero.secondaryCta && (
|
||||||
|
<Button href={hero.secondaryCta.href} variant={hero.secondaryCta.variant ?? 'secondary'}>
|
||||||
|
{hero.secondaryCta.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Cluster>
|
||||||
|
)}
|
||||||
|
{hero.highlights && hero.highlights.length > 0 && (
|
||||||
|
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
||||||
|
{
|
||||||
|
hero.highlights.map((highlight) => (
|
||||||
|
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
||||||
|
{highlight}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid gap-5">
|
||||||
|
{(calloutTitle || calloutDescription) && (
|
||||||
|
<Card variant="accent" class="motion-rise">
|
||||||
|
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
||||||
|
Trust-first launch surface
|
||||||
|
</p>
|
||||||
|
{calloutTitle && (
|
||||||
|
<h2 class="mt-4 font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
|
||||||
|
{calloutTitle}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{calloutDescription && (
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{calloutDescription}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.length > 0 && (
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
|
{
|
||||||
|
metrics.map((metric) => (
|
||||||
|
<Card variant="subtle">
|
||||||
|
<p class="m-0 text-3xl font-semibold tracking-[-0.04em] text-[var(--color-ink-900)]">
|
||||||
|
{metric.value}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm font-semibold uppercase tracking-[0.14em] text-[var(--color-brand)]">
|
||||||
|
{metric.label}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[var(--color-copy)]">{metric.description}</p>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
28
apps/website/src/components/sections/TrustGrid.astro
Normal file
28
apps/website/src/components/sections/TrustGrid.astro
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
import TrustPrincipleCard from '@/components/content/TrustPrincipleCard.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { TrustPrincipleContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
items: TrustPrincipleContent[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, eyebrow, items, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||||
|
<Grid cols="3">
|
||||||
|
{items.map((item) => <TrustPrincipleCard item={item} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
24
apps/website/src/content.config.ts
Normal file
24
apps/website/src/content.config.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
const futureContentSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
publishedAt: z.coerce.date().optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
articles: defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
|
||||||
|
schema: futureContentSchema,
|
||||||
|
}),
|
||||||
|
changelog: defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/changelog' }),
|
||||||
|
schema: futureContentSchema,
|
||||||
|
}),
|
||||||
|
resources: defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/resources' }),
|
||||||
|
schema: futureContentSchema,
|
||||||
|
}),
|
||||||
|
};
|
||||||
65
apps/website/src/content/pages/contact.ts
Normal file
65
apps/website/src/content/pages/contact.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||||
|
|
||||||
|
export const contactSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Contact',
|
||||||
|
description:
|
||||||
|
'TenantAtlas uses a qualified working-session path instead of a generic demo pitch so serious buyers can frame the right conversation early.',
|
||||||
|
path: '/contact',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contactHero: HeroContent = {
|
||||||
|
eyebrow: 'Contact / Demo',
|
||||||
|
title: 'Start a qualified working session instead of a generic demo request.',
|
||||||
|
description:
|
||||||
|
'The contact path should help serious buyers explain who they are, what governance questions they are trying to solve, and what kind of follow-up would actually be useful.',
|
||||||
|
primaryCta: {
|
||||||
|
href: 'mailto:hello@tenantatlas.example?subject=TenantAtlas%20working%20session',
|
||||||
|
label: 'Email the TenantAtlas team',
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/legal',
|
||||||
|
label: 'Read the legal surface',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Use the page to qualify the conversation, not to force a form funnel.',
|
||||||
|
'Set expectations for what the session covers and what happens next.',
|
||||||
|
'Keep privacy and terms visible before anyone shares evaluation details.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contactTopics = [
|
||||||
|
'Evaluation of backup, restore, or version-governance workflows for Intune and Microsoft tenant operations.',
|
||||||
|
'Questions about MSP fit, customer-facing evidence, or multi-tenant operational discipline.',
|
||||||
|
'Internal enterprise review of change history, drift visibility, evidence collection, or restore posture.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const contactPrompts = [
|
||||||
|
{
|
||||||
|
title: 'Good first conversation',
|
||||||
|
description:
|
||||||
|
'Explain the current operating model, where version history breaks down today, and which changes feel hardest to review or restore safely.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Useful context to share',
|
||||||
|
description:
|
||||||
|
'Team shape, tenant count, policy complexity, change frequency, and whether the first concern is restore safety, auditability, or review evidence.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const contactPreview = {
|
||||||
|
message:
|
||||||
|
'We operate Microsoft tenant governance across multiple environments and want to understand how TenantAtlas approaches version history, safer restore flows, drift visibility, and review evidence.',
|
||||||
|
topic: 'Environment and operating model summary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contactLegalSections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Before you reach out',
|
||||||
|
body: [
|
||||||
|
'Use the legal links below before sharing evaluation details so the contact path stays trustworthy and unsurprising.',
|
||||||
|
'The legal hub, privacy page, and public website terms remain reachable from the contact flow and the global footer.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
145
apps/website/src/content/pages/home.ts
Normal file
145
apps/website/src/content/pages/home.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import type {
|
||||||
|
CalloutContent,
|
||||||
|
FeatureItemContent,
|
||||||
|
HeroContent,
|
||||||
|
IntegrationEntry,
|
||||||
|
MetricItem,
|
||||||
|
PageSeo,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const homeSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Governance of record for Microsoft tenant operations',
|
||||||
|
description:
|
||||||
|
'Trust-first public framing for a Microsoft tenant governance product that connects backup, restore, version history, drift, findings, evidence, and reviews.',
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const homeHero: HeroContent = {
|
||||||
|
eyebrow: 'Public website v0',
|
||||||
|
title: 'TenantAtlas is the trust-first public site for Microsoft tenant change history, drift visibility, and safer operations.',
|
||||||
|
description:
|
||||||
|
'TenantAtlas gives MSP and enterprise teams one clear operating model for understanding what changed, what drifted, what needs review, and what can be restored without turning governance into a loose collection of disconnected tools.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/product',
|
||||||
|
label: 'See the product model',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/security-trust',
|
||||||
|
label: 'Review the trust posture',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Static, readable, and separate from app runtime concerns.',
|
||||||
|
'Built for trust conversations before a demo ever starts.',
|
||||||
|
'Designed to scale into docs, changelog, and deeper resources later.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const homeMetrics: MetricItem[] = [
|
||||||
|
{
|
||||||
|
value: '1',
|
||||||
|
label: 'Connected model',
|
||||||
|
description: 'Inventory, snapshots, review evidence, and restore posture stay in one narrative.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '7+',
|
||||||
|
label: 'Core public surfaces',
|
||||||
|
description: 'Visitors can move from explanation to trust and contact without dead ends.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '0',
|
||||||
|
label: 'Runtime coupling',
|
||||||
|
description: 'The website stays independent from platform auth, session, and API behavior.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const homePillars: FeatureItemContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Inventory',
|
||||||
|
title: 'Normalize what the tenant really looks like right now.',
|
||||||
|
description:
|
||||||
|
'Start with the observed state so teams can inspect the current configuration baseline before they talk about restore or enforcement.',
|
||||||
|
meta: 'Last observed truth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Snapshots',
|
||||||
|
title: 'Keep immutable history instead of vague memory.',
|
||||||
|
description:
|
||||||
|
'Version history stays queryable by tenant, operator, and moment in time so teams can explain what changed and why.',
|
||||||
|
meta: 'Reproducible versions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Drift & findings',
|
||||||
|
title: 'Surface drift, exceptions, and review needs in the same language.',
|
||||||
|
description:
|
||||||
|
'Operational questions move from “what broke?” to “what changed, what matters, and what review is due?”',
|
||||||
|
meta: 'Review-oriented visibility',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Restore',
|
||||||
|
title: 'Treat rollback and restore as governed actions, not panic buttons.',
|
||||||
|
description:
|
||||||
|
'Preview, validation, and operator confirmation stay central so risky changes are reversible without becoming casual.',
|
||||||
|
meta: 'Safer execution',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Evidence',
|
||||||
|
title: 'Connect reviews, findings, and evidence without a second reporting layer.',
|
||||||
|
description:
|
||||||
|
'Teams can show why a configuration is acceptable, where exceptions exist, and how review decisions stay attributable.',
|
||||||
|
meta: 'Audit-ready context',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Operations',
|
||||||
|
title: 'Keep velocity without hiding risk.',
|
||||||
|
description:
|
||||||
|
'The product is built for admins who need speed and auditability at the same time, not for dashboards that only summarize after the fact.',
|
||||||
|
meta: 'Operator-first workflows',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const homeProofBlocks: CalloutContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Positioning',
|
||||||
|
title: 'Governance of record for Microsoft tenant operations.',
|
||||||
|
description:
|
||||||
|
'The site makes the category legible up front: not just backup, not just reporting, and not a second admin portal trying to mirror every Microsoft screen.',
|
||||||
|
tone: 'accent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Why it matters now',
|
||||||
|
title: 'Microsoft tenant change volume keeps climbing while operator certainty keeps shrinking.',
|
||||||
|
description:
|
||||||
|
'When policy history, restore posture, findings, and evidence live in separate conversations, teams lose time exactly when they need clarity.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Public promise',
|
||||||
|
title: 'No inflated compliance or automation claims.',
|
||||||
|
description:
|
||||||
|
'The public story stays grounded in what the product can honestly support at launch: version truth, safer restore flows, drift visibility, and review support.',
|
||||||
|
tone: 'subtle',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const homeEcosystem: IntegrationEntry[] = [
|
||||||
|
{
|
||||||
|
category: 'Microsoft',
|
||||||
|
name: 'Microsoft Graph',
|
||||||
|
summary: 'Graph-backed inventory and restore direction without pretending the website depends on live tenant access.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Identity',
|
||||||
|
name: 'Entra ID',
|
||||||
|
summary: 'Identity and access context remain part of the governance narrative where they matter to change control.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Endpoint',
|
||||||
|
name: 'Intune',
|
||||||
|
summary: 'Configuration state, backup, and restore posture stay central to the public product story.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Review',
|
||||||
|
name: 'Evidence workflows',
|
||||||
|
summary: 'Review packs, exceptions, and evidence stay connected to operational reality instead of becoming detached reporting artifacts.',
|
||||||
|
},
|
||||||
|
];
|
||||||
86
apps/website/src/content/pages/integrations.ts
Normal file
86
apps/website/src/content/pages/integrations.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
FeatureItemContent,
|
||||||
|
HeroContent,
|
||||||
|
IntegrationEntry,
|
||||||
|
PageSeo,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const integrationsSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Integrations',
|
||||||
|
description:
|
||||||
|
'TenantAtlas describes the Microsoft-centric ecosystem it fits today without turning the page into a public wishlist.',
|
||||||
|
path: '/integrations',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const integrationsHero: HeroContent = {
|
||||||
|
eyebrow: 'Ecosystem fit',
|
||||||
|
title: 'Stay clear about the ecosystem fit without turning the page into a wishlist.',
|
||||||
|
description:
|
||||||
|
'This page should show the real systems TenantAtlas is built around, the workflows it expects to support, and the deliberate boundaries where speculative integration language would create more noise than trust.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Plan the working session',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/product',
|
||||||
|
label: 'Revisit the product model',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Microsoft-first and governance-led.',
|
||||||
|
'Real direction only, no catalog-padding.',
|
||||||
|
'Explains fit without implying runtime coupling to the public site.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const integrationEntries: IntegrationEntry[] = [
|
||||||
|
{
|
||||||
|
category: 'Microsoft core',
|
||||||
|
name: 'Microsoft Graph',
|
||||||
|
summary:
|
||||||
|
'Graph remains the primary contract path for inventory, history, and restore-oriented product behavior in the platform.',
|
||||||
|
note: 'Core product contract',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Identity',
|
||||||
|
name: 'Entra ID',
|
||||||
|
summary:
|
||||||
|
'Identity context matters where tenant change, privileged access, or governance reviews intersect with Microsoft tenant administration.',
|
||||||
|
note: 'Operational context',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Endpoint',
|
||||||
|
name: 'Intune',
|
||||||
|
summary:
|
||||||
|
'Intune configuration state is central to the current product story, especially for version history, backup, restore, and drift visibility.',
|
||||||
|
note: 'Current release truth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Evidence',
|
||||||
|
name: 'Review & evidence workflows',
|
||||||
|
summary:
|
||||||
|
'Exports, review packs, and evidence-linked conversations should remain grounded in the actual tenant object and its change history.',
|
||||||
|
note: 'Governance workflow fit',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const integrationRules: FeatureItemContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Direction',
|
||||||
|
title: 'Integrations should reinforce the governance model.',
|
||||||
|
description:
|
||||||
|
'The page is not a marketplace list. It should show which systems matter because they change the product workflow, evidence story, or operator context.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Boundaries',
|
||||||
|
title: 'Do not imply shared public-site runtime dependencies.',
|
||||||
|
description:
|
||||||
|
'The website stays static and independent even while the product story references Graph, Intune, and Microsoft tenant governance flows.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Credibility',
|
||||||
|
title: 'Speculative wishlist entries reduce trust instead of creating momentum.',
|
||||||
|
description:
|
||||||
|
'The public integrations page should stay shorter and sharper than a catalog full of future ideas that are not relevant to launch truth.',
|
||||||
|
},
|
||||||
|
];
|
||||||
44
apps/website/src/content/pages/legal.ts
Normal file
44
apps/website/src/content/pages/legal.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||||
|
|
||||||
|
export const legalSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Legal',
|
||||||
|
description:
|
||||||
|
'The TenantAtlas legal surface keeps privacy, website terms, and public legal-notice routing accessible before or during buyer conversations.',
|
||||||
|
path: '/legal',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const legalHero: HeroContent = {
|
||||||
|
eyebrow: 'Legal surface',
|
||||||
|
title: 'Legal access should stay one click away from the contact path.',
|
||||||
|
description:
|
||||||
|
'The legal hub keeps privacy, website terms, and public legal-notice information discoverable from the footer and the conversion flow so visitors do not have to guess where those basics live.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/privacy',
|
||||||
|
label: 'Privacy',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/terms',
|
||||||
|
label: 'Terms',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Public legal basics stay reachable before a visitor shares evaluation context.',
|
||||||
|
'The site separates website disclosures from future product-commercial paperwork.',
|
||||||
|
'Jurisdiction-specific notice has a dedicated home in the legal surface.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const legalNoticeSections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Public legal notice',
|
||||||
|
body: [
|
||||||
|
'This v0 legal surface reserves the public location for operating-entity, registration, address, and jurisdiction-specific disclosure details that must be published before a broad public launch.',
|
||||||
|
'During controlled evaluation, legal and privacy inquiries can be routed through the public contact path while those final publisher details are being finalized.',
|
||||||
|
],
|
||||||
|
bullets: [
|
||||||
|
'Operating entity and jurisdictional disclosure fields belong in this legal hub before launch.',
|
||||||
|
'Privacy and website terms stay published as standalone routes now.',
|
||||||
|
'The legal hub remains the single public path for future launch-required disclosures.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
60
apps/website/src/content/pages/privacy.ts
Normal file
60
apps/website/src/content/pages/privacy.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||||
|
|
||||||
|
export const privacySeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Privacy',
|
||||||
|
description:
|
||||||
|
'Public-site privacy overview for TenantAtlas inquiries, including how contact details and evaluation context are handled on the public website.',
|
||||||
|
path: '/privacy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const privacyHero: HeroContent = {
|
||||||
|
eyebrow: 'Privacy',
|
||||||
|
title: 'Public-site privacy overview for TenantAtlas inquiries.',
|
||||||
|
description:
|
||||||
|
'This page explains the privacy expectations for the public website and the contact path, rather than promising a full product-tenant data processing agreement from a static marketing surface.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Return to contact',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/terms',
|
||||||
|
label: 'Review website terms',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'The public site should only request information that supports a useful follow-up.',
|
||||||
|
'Contact details and evaluation context should be handled carefully and minimally.',
|
||||||
|
'Future product-processing details belong in product/legal agreements, not hidden marketing copy.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const privacySections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Scope',
|
||||||
|
body: [
|
||||||
|
'This privacy overview applies to the public TenantAtlas website and to information a visitor intentionally shares through the public contact path.',
|
||||||
|
'It does not describe tenant data processing inside the product itself, which belongs in product-specific legal and contractual materials.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Information you choose to send',
|
||||||
|
body: [
|
||||||
|
'If you contact the team, the information you provide may include your name, company, role, email address, and the evaluation or governance questions you want to discuss.',
|
||||||
|
'The site should not ask for unnecessary secrets, production credentials, or tenant data through the public contact path.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Use and retention',
|
||||||
|
body: [
|
||||||
|
'Information shared through the public contact path is used to understand the inquiry, respond to the request, and coordinate a relevant follow-up conversation.',
|
||||||
|
'Public-site inquiry information should be retained only for as long as needed to manage the evaluation discussion and related follow-up.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Questions and updates',
|
||||||
|
body: [
|
||||||
|
'Privacy questions can be routed through the public contact path until the final launch legal notice publishes the full operating-entity details for privacy correspondence.',
|
||||||
|
'If the public-site data handling model changes materially, this page should be updated before or at the same time as the change.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
110
apps/website/src/content/pages/product.ts
Normal file
110
apps/website/src/content/pages/product.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type {
|
||||||
|
CalloutContent,
|
||||||
|
FeatureItemContent,
|
||||||
|
HeroContent,
|
||||||
|
MetricItem,
|
||||||
|
PageSeo,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const productSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Product',
|
||||||
|
description:
|
||||||
|
'TenantAtlas connects inventory, snapshots, restore safety, drift visibility, findings, exceptions, and evidence into one governance model.',
|
||||||
|
path: '/product',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const productHero: HeroContent = {
|
||||||
|
eyebrow: 'Product model',
|
||||||
|
title: 'One operating model for change history, drift visibility, and review readiness.',
|
||||||
|
description:
|
||||||
|
'TenantAtlas treats Microsoft tenant governance as one connected system: observe the current state, preserve immutable history, detect meaningful change, and support reviews or restores with the context operators actually need.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/solutions',
|
||||||
|
label: 'See audience fit',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Talk through your current operating model',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Inventory first, snapshots second.',
|
||||||
|
'Restore flows stay previewable and attributable.',
|
||||||
|
'Evidence and review posture stay connected to real change history.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const productMetrics: MetricItem[] = [
|
||||||
|
{
|
||||||
|
value: '4',
|
||||||
|
label: 'Operator questions',
|
||||||
|
description: 'What changed? Why does it matter? What can be restored? What needs review now?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '100%',
|
||||||
|
label: 'Queryable versions',
|
||||||
|
description: 'Version semantics stay tied to who changed what, when, and in which tenant context.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const productModelBlocks: FeatureItemContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Connected governance model',
|
||||||
|
title: 'Inventory creates the starting point for every other decision.',
|
||||||
|
description:
|
||||||
|
'The product begins with the last observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Connected governance model',
|
||||||
|
title: 'Snapshots add immutable history without replacing current truth.',
|
||||||
|
description:
|
||||||
|
'Backups and versions are explicit artifacts. They preserve what was seen at a point in time while keeping the present-tense inventory readable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Connected governance model',
|
||||||
|
title: 'Restore is handled as a governed operation, not as a blind push.',
|
||||||
|
description:
|
||||||
|
'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Drift visibility',
|
||||||
|
title: 'Differences become reviewable signals instead of noisy raw deltas.',
|
||||||
|
description:
|
||||||
|
'Human-readable summaries and structured differences help operators and reviewers decide what changed and what needs action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Exceptions & evidence',
|
||||||
|
title: 'Findings, exceptions, and evidence stay anchored to operational truth.',
|
||||||
|
description:
|
||||||
|
'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Operator safety',
|
||||||
|
title: 'Auditability is part of the product shape, not a later add-on.',
|
||||||
|
description:
|
||||||
|
'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const productNarrative: CalloutContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Why it is not a feature list',
|
||||||
|
title: 'The point is not “backup plus reporting plus restore.”',
|
||||||
|
description:
|
||||||
|
'The point is to reduce operator uncertainty by keeping those capabilities connected through the same source material and the same decision flow.',
|
||||||
|
tone: 'accent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'What teams get',
|
||||||
|
title: 'A calmer path from observation to action.',
|
||||||
|
description:
|
||||||
|
'Teams can move from understanding the current tenant state to comparing history, planning remediation, or reviewing restore options without leaving the product model behind.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'What teams avoid',
|
||||||
|
title: 'No generic dashboard theater.',
|
||||||
|
description:
|
||||||
|
'The product story avoids pretending that another alerting page or compliance badge alone solves governance discipline.',
|
||||||
|
tone: 'subtle',
|
||||||
|
},
|
||||||
|
];
|
||||||
78
apps/website/src/content/pages/security-trust.ts
Normal file
78
apps/website/src/content/pages/security-trust.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import type {
|
||||||
|
CalloutContent,
|
||||||
|
HeroContent,
|
||||||
|
PageSeo,
|
||||||
|
TrustPrincipleContent,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const securityTrustSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Security & Trust',
|
||||||
|
description:
|
||||||
|
'TenantAtlas frames trust through substantiated product posture, safer restore discipline, and operational clarity rather than inflated guarantees.',
|
||||||
|
path: '/security-trust',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const securityTrustHero: HeroContent = {
|
||||||
|
eyebrow: 'Security & Trust',
|
||||||
|
title: 'Explain the trust posture in the language of operational controls, not marketing claims.',
|
||||||
|
description:
|
||||||
|
'The public trust page should set realistic expectations: what the product helps teams observe, preserve, review, and restore, and where launch claims intentionally stay narrow until they can be substantiated further.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/legal',
|
||||||
|
label: 'Read the legal surface',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Discuss trust requirements',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Preview, confirmation, and auditability remain part of the restore story.',
|
||||||
|
'Public claims stay narrower than internal ambition.',
|
||||||
|
'No fake compliance theater for launch.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const securityPrinciples: TrustPrincipleContent[] = [
|
||||||
|
{
|
||||||
|
title: 'Safer changes are a product rule, not an afterthought.',
|
||||||
|
description:
|
||||||
|
'Destructive or high-risk flows are framed around preview, validation, and explicit confirmation instead of one-click confidence theater.',
|
||||||
|
note: 'Trust starts with operator discipline.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Operational evidence stays tied to the real object and version.',
|
||||||
|
description:
|
||||||
|
'Findings, exceptions, and reviews remain anchored to observable tenant state so the trust story is defensible when someone asks for proof.',
|
||||||
|
note: 'Evidence should stay attributable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Launch messaging stays within substantiated boundaries.',
|
||||||
|
description:
|
||||||
|
'The public site avoids promising certifications, guarantees, or full automation outcomes that are not yet appropriate to claim.',
|
||||||
|
note: 'Restraint is part of credibility.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const securityTrustNotes: CalloutContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Substantiated public posture',
|
||||||
|
title: 'Substantiated public posture',
|
||||||
|
description:
|
||||||
|
'The launch story focuses on product shape and operator safeguards: inventory truth, immutable snapshots, safer restore flows, drift visibility, and review support.',
|
||||||
|
tone: 'accent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Sensitive connections',
|
||||||
|
title: 'Sensitive Microsoft connections should be explained carefully.',
|
||||||
|
description:
|
||||||
|
'The public site acknowledges Graph- and tenant-facing access without pretending the site itself is part of the runtime trust boundary.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'What we will not say',
|
||||||
|
title: 'No blanket assurances and no vague “fully automated governance” language.',
|
||||||
|
description:
|
||||||
|
'Trust pages lose credibility quickly when they substitute slogans for the actual controls and workflows a buyer will later inspect.',
|
||||||
|
tone: 'subtle',
|
||||||
|
},
|
||||||
|
];
|
||||||
90
apps/website/src/content/pages/solutions.ts
Normal file
90
apps/website/src/content/pages/solutions.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type {
|
||||||
|
AudienceRowContent,
|
||||||
|
FeatureItemContent,
|
||||||
|
HeroContent,
|
||||||
|
PageSeo,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const solutionsSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Solutions',
|
||||||
|
description:
|
||||||
|
'TenantAtlas fits MSP and enterprise IT teams differently, and the public story should make those operating-model differences explicit.',
|
||||||
|
path: '/solutions',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const solutionsHero: HeroContent = {
|
||||||
|
eyebrow: 'Audience fit',
|
||||||
|
title: 'Show how TenantAtlas fits MSP delivery teams and enterprise operators without collapsing them into one generic story.',
|
||||||
|
description:
|
||||||
|
'The product helps different organizations answer similar governance questions, but the surrounding workflow, accountability, and evidence needs are not identical. The site should acknowledge that directly.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/integrations',
|
||||||
|
label: 'Review the ecosystem fit',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Talk through your evaluation path',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Separate MSP and enterprise language on purpose.',
|
||||||
|
'Keep the product story stable while the buying context changes.',
|
||||||
|
'Avoid forcing every visitor through the same generic motion.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const solutionsAudiences: AudienceRowContent[] = [
|
||||||
|
{
|
||||||
|
audience: 'MSP',
|
||||||
|
title: 'MSP operating model',
|
||||||
|
description:
|
||||||
|
'Managed service providers need tenant-scoped operational truth, repeatable review workflows, and a way to explain change history to customers without drowning in manual evidence gathering.',
|
||||||
|
bullets: [
|
||||||
|
'Keep per-tenant history and restore posture reviewable during service delivery.',
|
||||||
|
'Support a higher tempo of customer change while preserving a clean audit story.',
|
||||||
|
'Give account and delivery teams a shared language for exceptions, findings, and follow-up.',
|
||||||
|
],
|
||||||
|
cta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Discuss MSP delivery fit',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
audience: 'Enterprise IT',
|
||||||
|
title: 'Enterprise IT operating model',
|
||||||
|
description:
|
||||||
|
'Internal IT and security teams need durable version truth, change visibility, and review evidence that can stand up to operational leadership, audit, and cross-team scrutiny.',
|
||||||
|
bullets: [
|
||||||
|
'Reduce uncertainty around who changed what and when across the Microsoft tenant surface.',
|
||||||
|
'Support internal review packs, exception handling, and evidence collection without fragmented tooling.',
|
||||||
|
'Keep restore and remediation conversations grounded in the current tenant state and the relevant history.',
|
||||||
|
],
|
||||||
|
cta: {
|
||||||
|
href: '/security-trust',
|
||||||
|
label: 'Inspect the trust posture',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const solutionsSignals: FeatureItemContent[] = [
|
||||||
|
{
|
||||||
|
eyebrow: 'Why buyers care',
|
||||||
|
title: 'The product is serious about both velocity and control.',
|
||||||
|
description:
|
||||||
|
'Teams can move quickly without giving up visibility, confirmation discipline, or explainability when a risky change needs review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Where it lands',
|
||||||
|
title: 'The product belongs in the operating layer, not just the reporting layer.',
|
||||||
|
description:
|
||||||
|
'Visitors should understand that TenantAtlas helps teams make safer decisions about configuration state rather than merely summarize activity afterward.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'How it reads',
|
||||||
|
title: 'The story changes by audience, but the product truth does not.',
|
||||||
|
description:
|
||||||
|
'MSP and enterprise readers see their own operating concerns reflected without the site inventing two different products.',
|
||||||
|
},
|
||||||
|
];
|
||||||
60
apps/website/src/content/pages/terms.ts
Normal file
60
apps/website/src/content/pages/terms.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { HeroContent, LegalSection, PageSeo } from '@/types/site';
|
||||||
|
|
||||||
|
export const termsSeo: PageSeo = {
|
||||||
|
title: 'TenantAtlas | Terms',
|
||||||
|
description:
|
||||||
|
'Website terms for the public TenantAtlas surface, covering informational use of the site and the limits of public product statements.',
|
||||||
|
path: '/terms',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const termsHero: HeroContent = {
|
||||||
|
eyebrow: 'Website terms',
|
||||||
|
title: 'Website terms for the public TenantAtlas surface.',
|
||||||
|
description:
|
||||||
|
'These terms describe the public website itself: informational use of the content, basic conduct expectations, and the fact that a public product site is not the same thing as a signed service agreement.',
|
||||||
|
primaryCta: {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Return to contact',
|
||||||
|
},
|
||||||
|
secondaryCta: {
|
||||||
|
href: '/privacy',
|
||||||
|
label: 'Review privacy',
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
'Public copy explains the product but does not replace commercial agreements.',
|
||||||
|
'The site is for evaluation and information, not operational control of a tenant.',
|
||||||
|
'Any future service commitment belongs in explicit signed terms.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const termsSections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Informational website use',
|
||||||
|
body: [
|
||||||
|
'The public TenantAtlas website is provided to explain the product category, trust posture, integrations direction, and contact path for evaluation conversations.',
|
||||||
|
'Nothing on the public site should be interpreted as a guarantee of product availability, certification, or commercial commitment unless it is later confirmed in signed agreements.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reasonable reliance',
|
||||||
|
body: [
|
||||||
|
'Visitors may use the public site to understand the product and decide whether to start a conversation, but they should not rely on public marketing pages as the sole source of contractual or implementation truth.',
|
||||||
|
'Detailed service commitments, security terms, and procurement obligations belong in later commercial and legal documentation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Acceptable conduct',
|
||||||
|
body: [
|
||||||
|
'Visitors should use the public website lawfully and should not attempt to interfere with the availability or integrity of the public site.',
|
||||||
|
'The public website is not a runtime administration surface and should not be treated as one.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Questions',
|
||||||
|
body: [
|
||||||
|
'Questions about the public website terms, privacy, or future product legal materials can be routed through the public contact path.',
|
||||||
|
'The legal hub remains the public anchor for later launch-ready legal disclosures.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -1,15 +1,26 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../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 {
|
||||||
description = 'TenantPilot keeps Intune governance observable, reviewable, and safe to operate.',
|
canonicalUrl,
|
||||||
title = 'TenantPilot',
|
description = siteMetadata.siteDescription,
|
||||||
|
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>
|
||||||
@ -17,11 +28,22 @@ const {
|
|||||||
<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>
|
||||||
|
|||||||
27
apps/website/src/lib/seo.ts
Normal file
27
apps/website/src/lib/seo.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { coreRoutes, siteMetadata } from '@/lib/site';
|
||||||
|
import type { PageSeo } from '@/types/site';
|
||||||
|
|
||||||
|
export interface ResolvedSeo extends PageSeo {
|
||||||
|
canonicalUrl: string;
|
||||||
|
ogDescription: string;
|
||||||
|
ogTitle: string;
|
||||||
|
robots: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCanonicalUrl(path: string): string {
|
||||||
|
return new URL(path, siteMetadata.siteUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSeo(seo: PageSeo): ResolvedSeo {
|
||||||
|
return {
|
||||||
|
...seo,
|
||||||
|
canonicalUrl: buildCanonicalUrl(seo.path),
|
||||||
|
ogDescription: seo.ogDescription ?? seo.description,
|
||||||
|
ogTitle: seo.ogTitle ?? seo.title,
|
||||||
|
robots: 'index,follow',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sitemapEntries(): string[] {
|
||||||
|
return [...coreRoutes].map((path) => buildCanonicalUrl(path));
|
||||||
|
}
|
||||||
76
apps/website/src/lib/site.ts
Normal file
76
apps/website/src/lib/site.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import type {
|
||||||
|
CtaLink,
|
||||||
|
FooterNavigationGroup,
|
||||||
|
NavigationItem,
|
||||||
|
SiteMetadata,
|
||||||
|
} from '@/types/site';
|
||||||
|
|
||||||
|
export const siteMetadata: SiteMetadata = {
|
||||||
|
siteName: 'TenantAtlas',
|
||||||
|
siteTagline: 'Governance of record for Microsoft tenant operations.',
|
||||||
|
siteDescription:
|
||||||
|
'TenantAtlas helps MSP and enterprise teams keep Microsoft tenant change history observable, reviewable, and safer to operate.',
|
||||||
|
siteUrl: import.meta.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const primaryNavigation: NavigationItem[] = [
|
||||||
|
{ href: '/product', label: 'Product', description: 'Understand the operating model.' },
|
||||||
|
{ href: '/solutions', label: 'Solutions', description: 'See the fit for MSP and enterprise teams.' },
|
||||||
|
{ href: '/security-trust', label: 'Security & Trust', description: 'Review the product posture.' },
|
||||||
|
{ href: '/integrations', label: 'Integrations', description: 'Inspect the real ecosystem fit.' },
|
||||||
|
{ href: '/contact', label: 'Contact', description: 'Reach the team for a working session.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const footerNavigationGroups: FooterNavigationGroup[] = [
|
||||||
|
{
|
||||||
|
title: 'Explore',
|
||||||
|
items: [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/product', label: 'Product' },
|
||||||
|
{ href: '/solutions', label: 'Solutions' },
|
||||||
|
{ href: '/security-trust', label: 'Security & Trust' },
|
||||||
|
{ href: '/integrations', label: 'Integrations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Next step',
|
||||||
|
items: [
|
||||||
|
{ href: '/contact', label: 'Contact / Demo' },
|
||||||
|
{ href: '/legal', label: 'Legal' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Legal',
|
||||||
|
items: [
|
||||||
|
{ href: '/privacy', label: 'Privacy' },
|
||||||
|
{ href: '/terms', label: 'Terms' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const contactCta: CtaLink = {
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Request a working session',
|
||||||
|
helper: 'Bring your governance questions, rollout concerns, or evaluation goals.',
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coreRoutes = [
|
||||||
|
'/',
|
||||||
|
'/product',
|
||||||
|
'/solutions',
|
||||||
|
'/security-trust',
|
||||||
|
'/integrations',
|
||||||
|
'/contact',
|
||||||
|
'/legal',
|
||||||
|
'/privacy',
|
||||||
|
'/terms',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function isActiveNavigationPath(currentPath: string, href: string): boolean {
|
||||||
|
if (href === '/') {
|
||||||
|
return currentPath === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
98
apps/website/src/pages/contact.astro
Normal file
98
apps/website/src/pages/contact.astro
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
import ContactPanel from '@/components/content/ContactPanel.astro';
|
||||||
|
import DemoPrompt from '@/components/content/DemoPrompt.astro';
|
||||||
|
import RichText from '@/components/content/RichText.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Cluster from '@/components/primitives/Cluster.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Input from '@/components/primitives/Input.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import Textarea from '@/components/primitives/Textarea.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import {
|
||||||
|
contactHero,
|
||||||
|
contactLegalSections,
|
||||||
|
contactPreview,
|
||||||
|
contactPrompts,
|
||||||
|
contactSeo,
|
||||||
|
contactTopics,
|
||||||
|
} from '@/content/pages/contact';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/contact" title={contactSeo.title} description={contactSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={contactHero}
|
||||||
|
calloutTitle="Qualified conversations beat anonymous form funnels."
|
||||||
|
calloutDescription="The page should make it obvious who should reach out, why, and what a useful first exchange looks like."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<Grid cols="3">
|
||||||
|
<ContactPanel cta={contactHero.primaryCta} points={contactTopics} title="Who should get in touch" />
|
||||||
|
{contactPrompts.map((prompt) => <DemoPrompt title={prompt.title} description={prompt.description} />)}
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[1fr,0.8fr]">
|
||||||
|
<Card>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Suggested note"
|
||||||
|
title="Give the team enough context to make the first reply useful."
|
||||||
|
description="The public path stays static in v0, but it can still help a serious buyer structure the first message."
|
||||||
|
/>
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<Input readonly value={contactPreview.topic} />
|
||||||
|
<Textarea readonly rows={7} value={contactPreview.message} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="accent">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Before you share details"
|
||||||
|
title="Legal basics stay visible from the contact flow."
|
||||||
|
description="Visitors should be able to inspect privacy and terms before they continue."
|
||||||
|
/>
|
||||||
|
<Cluster class="mt-6">
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/terms"
|
||||||
|
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/legal"
|
||||||
|
class="inline-flex min-h-11 items-center justify-center rounded-full border border-[color:var(--color-line)] bg-white/85 px-5 text-sm font-semibold text-[var(--color-ink-900)]"
|
||||||
|
>
|
||||||
|
Legal
|
||||||
|
</a>
|
||||||
|
</Cluster>
|
||||||
|
<div class="mt-6">
|
||||||
|
<RichText sections={contactLegalSections} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Next step"
|
||||||
|
title="Move from a public introduction into the legal and product details that support a real evaluation."
|
||||||
|
description="The contact route should never strand a serious buyer. The next path stays visible whether they need product context, privacy details, or website terms."
|
||||||
|
primary={{ href: '/legal', label: 'Read the legal surface' }}
|
||||||
|
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
@ -1,66 +1,65 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import Callout from '@/components/content/Callout.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||||
|
import LogoStrip from '@/components/sections/LogoStrip.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import {
|
||||||
|
homeEcosystem,
|
||||||
|
homeHero,
|
||||||
|
homeMetrics,
|
||||||
|
homePillars,
|
||||||
|
homeProofBlocks,
|
||||||
|
homeSeo,
|
||||||
|
} from '@/content/pages/home';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<PageShell currentPath="/" title={homeSeo.title} description={homeSeo.description}>
|
||||||
title="TenantPilot | Workspace Foundation"
|
<PageHero
|
||||||
description="The first public TenantPilot website surface for workspace-safe Intune operations."
|
hero={homeHero}
|
||||||
>
|
metrics={homeMetrics}
|
||||||
<main class="page-shell">
|
calloutTitle="Governance of record for Microsoft tenant operations."
|
||||||
<section class="hero">
|
calloutDescription="The public story positions TenantAtlas as a trust-first system for version truth, safer restore posture, drift visibility, evidence, and review support."
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
<LogoStrip
|
||||||
<a class="primary-action" href="#workspace-model">View the workspace model</a>
|
eyebrow="Ecosystem fit"
|
||||||
<a class="secondary-action" href="#boundaries">Review the isolation rules</a>
|
title="Built around the Microsoft tenant reality buyers already need to govern."
|
||||||
|
items={homeEcosystem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="Product pillars"
|
||||||
|
title="Explain the product in connected pillars, not isolated promises."
|
||||||
|
description="Each section of the site should help a first-time visitor understand why backup, restore, findings, evidence, and reviews belong together."
|
||||||
|
items={homePillars}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Public proof"
|
||||||
|
title="A credible first reading should answer the buyer’s next two questions before they ask them."
|
||||||
|
description="Why is this product category needed now, and why should anyone trust the story enough to continue?"
|
||||||
|
/>
|
||||||
|
<Grid cols="3">
|
||||||
|
{homeProofBlocks.map((block) => <Callout content={block} />)}
|
||||||
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<section class="signal-grid" id="workspace-model" aria-label="Workspace foundations">
|
<CTASection
|
||||||
<article class="signal-card">
|
eyebrow="Next step"
|
||||||
<p class="signal-label">Platform</p>
|
title="Move from first-glance clarity into the deeper product story."
|
||||||
<h2>Laravel stays in <code>apps/platform</code>.</h2>
|
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."
|
||||||
<p>
|
primary={{ href: '/product', label: 'See the product model' }}
|
||||||
Sail, Filament, Livewire, and deployment-sensitive runtime concerns remain
|
secondary={{ href: '/contact', label: 'Start the working session', variant: 'secondary' }}
|
||||||
platform-owned and unchanged.
|
/>
|
||||||
</p>
|
</PageShell>
|
||||||
</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>
|
|
||||||
|
|||||||
55
apps/website/src/pages/integrations.astro
Normal file
55
apps/website/src/pages/integrations.astro
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
import IntegrationBadge from '@/components/content/IntegrationBadge.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import {
|
||||||
|
integrationEntries,
|
||||||
|
integrationRules,
|
||||||
|
integrationsHero,
|
||||||
|
integrationsSeo,
|
||||||
|
} from '@/content/pages/integrations';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/integrations" title={integrationsSeo.title} description={integrationsSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={integrationsHero}
|
||||||
|
calloutTitle="Real direction beats a longer wishlist."
|
||||||
|
calloutDescription="The integrations page should reinforce where the product actually fits today and why those boundaries improve trust rather than limit it."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Current direction"
|
||||||
|
title="Show the systems that shape the product workflow today."
|
||||||
|
description="This page should stay focused on the contracts and ecosystems that matter to Microsoft tenant governance work now."
|
||||||
|
/>
|
||||||
|
<Grid cols="2">
|
||||||
|
{integrationEntries.map((item) => <IntegrationBadge item={item} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="Page rules"
|
||||||
|
title="Use the integrations page to clarify scope, not to perform ambition."
|
||||||
|
description="The public site becomes more credible when it names the real ecosystem fit and avoids presenting speculative adjacencies as if they were launch truth."
|
||||||
|
items={integrationRules}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Next step"
|
||||||
|
title="Turn ecosystem fit into a practical evaluation conversation."
|
||||||
|
description="Once a buyer sees the Microsoft-centric fit, the next useful step is a working session about their current environment, governance needs, and rollout questions."
|
||||||
|
primary={{ href: '/contact', label: 'Plan the working session' }}
|
||||||
|
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
75
apps/website/src/pages/legal.astro
Normal file
75
apps/website/src/pages/legal.astro
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
import RichText from '@/components/content/RichText.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import { legalHero, legalNoticeSections, legalSeo } from '@/content/pages/legal';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/legal" title={legalSeo.title} description={legalSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={legalHero}
|
||||||
|
calloutTitle="Public legal basics belong in one obvious place."
|
||||||
|
calloutDescription="The legal hub keeps the conversion path honest by making privacy, terms, and notice routing easy to find before or during evaluation conversations."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Available now"
|
||||||
|
title="Privacy and terms are published as standalone routes."
|
||||||
|
description="The legal hub should work as an index and as the public home for launch-required legal notices."
|
||||||
|
/>
|
||||||
|
<Grid cols="3">
|
||||||
|
<Card>
|
||||||
|
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Privacy</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||||
|
Review how the public contact path handles inquiry information and what this static website does not claim to process.
|
||||||
|
</p>
|
||||||
|
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="/privacy">
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Terms</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||||
|
Read the website terms that explain the public-site scope and why marketing pages do not replace signed agreements.
|
||||||
|
</p>
|
||||||
|
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="/terms">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
<Card variant="accent">
|
||||||
|
<h3 class="m-0 text-2xl font-semibold text-[var(--color-ink-900)]">Public legal notice</h3>
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">
|
||||||
|
This hub also owns the future launch-ready operator identity and jurisdiction-specific disclosure section.
|
||||||
|
</p>
|
||||||
|
<a class="mt-5 inline-flex text-sm font-semibold text-[var(--color-brand)]" href="#public-legal-notice">
|
||||||
|
Legal notice
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id="public-legal-notice">
|
||||||
|
<Container wide>
|
||||||
|
<RichText sections={legalNoticeSections} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Continue"
|
||||||
|
title="Return to the contact path once the legal basics are clear."
|
||||||
|
description="The legal surface should support a qualified conversation, not interrupt it."
|
||||||
|
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||||
|
secondary={{ href: '/product', label: 'Revisit the product model', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
31
apps/website/src/pages/privacy.astro
Normal file
31
apps/website/src/pages/privacy.astro
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
import RichText from '@/components/content/RichText.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import { privacyHero, privacySections, privacySeo } from '@/content/pages/privacy';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/privacy" title={privacySeo.title} description={privacySeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={privacyHero}
|
||||||
|
calloutTitle="Public-site privacy should stay narrow and readable."
|
||||||
|
calloutDescription="The page explains the website and inquiry path clearly without pretending to be the product’s full data-processing documentation."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<RichText sections={privacySections} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Next step"
|
||||||
|
title="Return to the product or contact flow after reviewing public-site privacy."
|
||||||
|
description="Visitors should be able to move back into the evaluation path without losing context."
|
||||||
|
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||||
|
secondary={{ href: '/terms', label: 'Review website terms', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
61
apps/website/src/pages/product.astro
Normal file
61
apps/website/src/pages/product.astro
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
import Callout from '@/components/content/Callout.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import {
|
||||||
|
productHero,
|
||||||
|
productMetrics,
|
||||||
|
productModelBlocks,
|
||||||
|
productNarrative,
|
||||||
|
productSeo,
|
||||||
|
} from '@/content/pages/product';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/product" title={productSeo.title} description={productSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={productHero}
|
||||||
|
metrics={productMetrics}
|
||||||
|
calloutTitle="Connected governance model"
|
||||||
|
calloutDescription="TenantAtlas connects present-state inventory, immutable snapshots, restore posture, drift, exceptions, and evidence so teams can explain what happened before they decide what to do next."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="Connected governance model"
|
||||||
|
title="Treat the product as one operating system for safer tenant change management."
|
||||||
|
description="This page explains how the pieces fit together so visitors do not mistake the product for a loose collection of backup, reporting, and restore features."
|
||||||
|
items={productModelBlocks}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Narrative"
|
||||||
|
title="Explain the operator journey, not just the capabilities."
|
||||||
|
description="The public product page should make it obvious how the product helps a team move from current-state understanding into reviewable action."
|
||||||
|
/>
|
||||||
|
<Grid cols="3">
|
||||||
|
{productNarrative.map((block) => <Callout content={block} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Continue"
|
||||||
|
title="Inspect whether the operating model fits your audience and workflow."
|
||||||
|
description="The next useful questions are who the product is for, how trust claims stay grounded, and what a working conversation with the team should cover."
|
||||||
|
primary={{ href: '/solutions', label: 'See audience fit' }}
|
||||||
|
secondary={{
|
||||||
|
href: '/contact',
|
||||||
|
label: 'Talk through your current operating model',
|
||||||
|
variant: 'secondary',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
59
apps/website/src/pages/security-trust.astro
Normal file
59
apps/website/src/pages/security-trust.astro
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import Callout from '@/components/content/Callout.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import TrustGrid from '@/components/sections/TrustGrid.astro';
|
||||||
|
import {
|
||||||
|
securityPrinciples,
|
||||||
|
securityTrustHero,
|
||||||
|
securityTrustNotes,
|
||||||
|
securityTrustSeo,
|
||||||
|
} from '@/content/pages/security-trust';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell
|
||||||
|
currentPath="/security-trust"
|
||||||
|
title={securityTrustSeo.title}
|
||||||
|
description={securityTrustSeo.description}
|
||||||
|
>
|
||||||
|
<PageHero
|
||||||
|
hero={securityTrustHero}
|
||||||
|
calloutTitle="Trust-first, not trust-theater."
|
||||||
|
calloutDescription="The page should help technical buyers see the operator safeguards and the intentional limits of the launch story before they hear a sales pitch."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TrustGrid
|
||||||
|
eyebrow="Product posture"
|
||||||
|
title="Operational trust starts with the way the product handles risky decisions."
|
||||||
|
description="The trust page should explain the guardrails that matter to a serious buyer: previewability, attributable change history, evidence linkage, and restrained public claims."
|
||||||
|
items={securityPrinciples}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Public messaging"
|
||||||
|
title="Substantiated public posture"
|
||||||
|
description="Keep the public trust story within the set of claims the team can support at launch."
|
||||||
|
/>
|
||||||
|
<Grid cols="3">
|
||||||
|
{securityTrustNotes.map((note) => <Callout content={note} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Next step"
|
||||||
|
title="Legal clarity and conversation path should stay reachable from the trust page."
|
||||||
|
description="A buyer evaluating trust should be able to move directly to public legal information or a working discussion without friction."
|
||||||
|
primary={{ href: '/legal', label: 'Read the legal surface' }}
|
||||||
|
secondary={{ href: '/contact', label: 'Discuss trust requirements', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
20
apps/website/src/pages/sitemap.xml.ts
Normal file
20
apps/website/src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
import { sitemapEntries } from '@/lib/seo';
|
||||||
|
|
||||||
|
export const GET: APIRoute = () => {
|
||||||
|
const urls = sitemapEntries()
|
||||||
|
.map((url) => ` <url><loc>${url}</loc></url>`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls}
|
||||||
|
</urlset>`;
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
55
apps/website/src/pages/solutions.astro
Normal file
55
apps/website/src/pages/solutions.astro
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
import AudienceRow from '@/components/content/AudienceRow.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import {
|
||||||
|
solutionsAudiences,
|
||||||
|
solutionsHero,
|
||||||
|
solutionsSeo,
|
||||||
|
solutionsSignals,
|
||||||
|
} from '@/content/pages/solutions';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/solutions" title={solutionsSeo.title} description={solutionsSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={solutionsHero}
|
||||||
|
calloutTitle="Audience-specific fit without product sprawl."
|
||||||
|
calloutDescription="The public site can speak differently to MSP and enterprise visitors while staying anchored to the same product truth."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Operating models"
|
||||||
|
title="Separate the delivery context clearly."
|
||||||
|
description="Visitors should be able to recognize themselves in the page quickly, without translating a generic story into their own workflow."
|
||||||
|
/>
|
||||||
|
<Grid cols="2">
|
||||||
|
{solutionsAudiences.map((item) => <AudienceRow item={item} />)}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<FeatureGrid
|
||||||
|
eyebrow="Buying signal"
|
||||||
|
title="Give the buyer a concrete reason to keep evaluating."
|
||||||
|
description="The goal is not to decorate the page with vertical tags. The goal is to show why the product belongs in the operating model for that audience."
|
||||||
|
items={solutionsSignals}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Continue"
|
||||||
|
title="Inspect the ecosystem fit after you understand the audience fit."
|
||||||
|
description="Once a visitor sees the product reflected in their operating model, the next useful question is how it fits the surrounding Microsoft tenant environment."
|
||||||
|
primary={{ href: '/integrations', label: 'Review the ecosystem fit' }}
|
||||||
|
secondary={{ href: '/contact', label: 'Talk through your evaluation path', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
31
apps/website/src/pages/terms.astro
Normal file
31
apps/website/src/pages/terms.astro
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
import RichText from '@/components/content/RichText.astro';
|
||||||
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import { termsHero, termsSections, termsSeo } from '@/content/pages/terms';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PageShell currentPath="/terms" title={termsSeo.title} description={termsSeo.description}>
|
||||||
|
<PageHero
|
||||||
|
hero={termsHero}
|
||||||
|
calloutTitle="Public terms define the site, not a service contract."
|
||||||
|
calloutDescription="The page keeps the public website honest about what it can explain and what still belongs in later commercial/legal paperwork."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Container wide>
|
||||||
|
<RichText sections={termsSections} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<CTASection
|
||||||
|
eyebrow="Next step"
|
||||||
|
title="Move back into privacy or contact once the website terms are clear."
|
||||||
|
description="The legal path should stay connected to the rest of the evaluation journey."
|
||||||
|
primary={{ href: '/contact', label: 'Return to contact' }}
|
||||||
|
secondary={{ href: '/privacy', label: 'Review privacy', variant: 'secondary' }}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
@ -1,221 +1,157 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./tokens.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f6efe5;
|
--color-ink-900: #11243a;
|
||||||
--bg-accent: #fffdf9;
|
--color-ink-800: #233a53;
|
||||||
--surface: rgba(255, 255, 255, 0.74);
|
--color-copy: #42556a;
|
||||||
--surface-strong: rgba(255, 255, 255, 0.92);
|
--color-line: rgba(17, 36, 58, 0.14);
|
||||||
--ink: #17120f;
|
--color-panel: rgba(255, 255, 255, 0.82);
|
||||||
--muted: #66584d;
|
--color-panel-strong: rgba(255, 255, 255, 0.95);
|
||||||
--line: rgba(23, 18, 15, 0.12);
|
--color-panel-soft: rgba(243, 247, 251, 0.86);
|
||||||
--accent: #cc5f2c;
|
--color-brand: #2f6fb7;
|
||||||
--accent-deep: #8b3820;
|
--color-brand-soft: rgba(47, 111, 183, 0.12);
|
||||||
--shadow: 0 30px 80px rgba(103, 52, 33, 0.16);
|
--color-signal: #3b8b78;
|
||||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
--color-warm: #af6d43;
|
||||||
}
|
--shadow-panel: 0 24px 80px rgba(17, 36, 58, 0.12);
|
||||||
|
--shadow-soft: 0 18px 48px rgba(17, 36, 58, 0.08);
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 201, 149, 0.55), transparent 34%),
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 32%),
|
||||||
radial-gradient(circle at right 12% top 10%, rgba(255, 145, 96, 0.18), transparent 24%),
|
radial-gradient(circle at top right, rgba(92, 149, 215, 0.18), transparent 28%),
|
||||||
linear-gradient(180deg, #fffaf3 0%, var(--bg) 58%, #efe3d5 100%);
|
linear-gradient(180deg, #f6f3ee 0%, #edf2f7 56%, #f3f7fb 100%);
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--ink);
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: "SFMono-Regular", "SF Mono", "IBM Plex Mono", monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.92em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-shell {
|
::selection {
|
||||||
width: min(1120px, calc(100% - 2rem));
|
background: rgba(47, 111, 183, 0.18);
|
||||||
margin: 0 auto;
|
color: var(--color-ink-900);
|
||||||
padding: 4.5rem 0 5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
:focus-visible {
|
||||||
.signal-card,
|
outline: 3px solid rgba(47, 111, 183, 0.32);
|
||||||
.boundary-panel {
|
outline-offset: 4px;
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
isolation: isolate;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero::after {
|
.surface-shell::before {
|
||||||
content: "";
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto -8rem -8rem auto;
|
inset: 0;
|
||||||
width: 18rem;
|
z-index: -2;
|
||||||
aspect-ratio: 1;
|
content: "";
|
||||||
border-radius: 999px;
|
background:
|
||||||
background: radial-gradient(circle, rgba(204, 95, 44, 0.22), transparent 72%);
|
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%);
|
||||||
|
}
|
||||||
.eyebrow,
|
|
||||||
.signal-label {
|
.surface-shell::after {
|
||||||
margin: 0 0 0.9rem;
|
position: absolute;
|
||||||
text-transform: uppercase;
|
inset: 1rem;
|
||||||
letter-spacing: 0.18em;
|
z-index: -1;
|
||||||
font-size: 0.78rem;
|
content: "";
|
||||||
font-weight: 700;
|
border: 1px solid rgba(17, 36, 58, 0.04);
|
||||||
color: var(--accent-deep);
|
border-radius: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1,
|
.skip-link {
|
||||||
.boundary-panel h2,
|
position: absolute;
|
||||||
.signal-card h2 {
|
top: 1rem;
|
||||||
margin: 0;
|
left: 1rem;
|
||||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
z-index: 40;
|
||||||
line-height: 0.95;
|
transform: translateY(-200%);
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
max-width: 13ch;
|
|
||||||
font-size: clamp(3rem, 8vw, 6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
max-width: 46rem;
|
|
||||||
margin: 1.5rem 0 0;
|
|
||||||
font-size: clamp(1.05rem, 2vw, 1.35rem);
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-action,
|
|
||||||
.secondary-action {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 3.25rem;
|
|
||||||
padding: 0.9rem 1.4rem;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
background: var(--color-ink-900);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 700;
|
transition: transform 140ms ease;
|
||||||
transition:
|
|
||||||
transform 180ms ease,
|
|
||||||
box-shadow 180ms ease,
|
|
||||||
background-color 180ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-action {
|
.skip-link:focus {
|
||||||
background: var(--ink);
|
transform: translateY(0);
|
||||||
color: #fff7f1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-action {
|
.glass-panel {
|
||||||
border: 1px solid rgba(23, 18, 15, 0.12);
|
background: linear-gradient(180deg, var(--color-panel-strong), var(--color-panel));
|
||||||
background: rgba(255, 255, 255, 0.5);
|
box-shadow: var(--shadow-panel);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-action:hover,
|
.section-divider {
|
||||||
.secondary-action:hover {
|
border-top: 1px solid rgba(17, 36, 58, 0.08);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-grid {
|
.legal-prose p {
|
||||||
display: grid;
|
margin: 0;
|
||||||
gap: 1.25rem;
|
color: var(--color-copy);
|
||||||
margin-top: 1.4rem;
|
line-height: 1.8;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-card,
|
.legal-prose p + p {
|
||||||
.boundary-panel {
|
margin-top: 1rem;
|
||||||
padding: 1.6rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-card h2 {
|
.legal-prose ul {
|
||||||
font-size: clamp(1.55rem, 3vw, 2.1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-card p:last-child,
|
|
||||||
.boundary-list {
|
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
color: var(--muted);
|
padding-left: 1.1rem;
|
||||||
line-height: 1.7;
|
color: var(--color-copy);
|
||||||
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boundary-panel {
|
.legal-prose li + li {
|
||||||
display: grid;
|
margin-top: 0.6rem;
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
background: var(--surface-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.boundary-panel h2 {
|
.motion-rise {
|
||||||
font-size: clamp(2rem, 4vw, 3.1rem);
|
animation: rise-in 520ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boundary-list {
|
@keyframes rise-in {
|
||||||
padding-left: 1.2rem;
|
from {
|
||||||
}
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
.boundary-list li + li {
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
|
||||||
.signal-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.page-shell {
|
|
||||||
width: min(100% - 1.25rem, 1120px);
|
|
||||||
padding-top: 2rem;
|
|
||||||
padding-bottom: 3rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
to {
|
||||||
.signal-card,
|
opacity: 1;
|
||||||
.boundary-panel {
|
transform: translateY(0);
|
||||||
border-radius: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
padding: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-action,
|
|
||||||
.secondary-action {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/website/src/styles/tokens.css
Normal file
19
apps/website/src/styles/tokens.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
@theme {
|
||||||
|
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
--font-display: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
|
||||||
|
--color-shell-50: oklch(0.985 0.01 86);
|
||||||
|
--color-shell-100: oklch(0.97 0.015 85);
|
||||||
|
--color-shell-200: oklch(0.935 0.025 84);
|
||||||
|
--color-shell-300: oklch(0.88 0.04 78);
|
||||||
|
--color-shell-900: oklch(0.19 0.03 65);
|
||||||
|
--color-shell-950: oklch(0.14 0.025 62);
|
||||||
|
--color-brand-400: oklch(0.76 0.09 210);
|
||||||
|
--color-brand-500: oklch(0.68 0.11 218);
|
||||||
|
--color-brand-700: oklch(0.49 0.11 228);
|
||||||
|
--color-signal-400: oklch(0.78 0.1 162);
|
||||||
|
--color-signal-700: oklch(0.52 0.08 170);
|
||||||
|
--color-warm-300: oklch(0.87 0.05 53);
|
||||||
|
--color-warm-500: oklch(0.69 0.09 48);
|
||||||
|
}
|
||||||
105
apps/website/src/types/site.ts
Normal file
105
apps/website/src/types/site.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
|
||||||
|
export type PageRole =
|
||||||
|
| 'home'
|
||||||
|
| 'product'
|
||||||
|
| 'solutions'
|
||||||
|
| 'trust'
|
||||||
|
| 'integrations'
|
||||||
|
| 'contact'
|
||||||
|
| 'legal';
|
||||||
|
|
||||||
|
export interface CtaLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
helper?: string;
|
||||||
|
target?: '_blank' | '_self';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterNavigationGroup {
|
||||||
|
items: NavigationItem[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteMetadata {
|
||||||
|
siteDescription: string;
|
||||||
|
siteName: string;
|
||||||
|
siteTagline: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageSeo {
|
||||||
|
description: string;
|
||||||
|
ogDescription?: string;
|
||||||
|
ogTitle?: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroContent {
|
||||||
|
description: string;
|
||||||
|
eyebrow: string;
|
||||||
|
highlights?: string[];
|
||||||
|
primaryCta: CtaLink;
|
||||||
|
secondaryCta?: CtaLink;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricItem {
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureItemContent {
|
||||||
|
description: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
href?: string;
|
||||||
|
meta?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalloutContent {
|
||||||
|
description: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
tone?: 'accent' | 'neutral' | 'subtle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudienceRowContent {
|
||||||
|
audience: string;
|
||||||
|
bullets: string[];
|
||||||
|
cta?: CtaLink;
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustPrincipleContent {
|
||||||
|
description: string;
|
||||||
|
note?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationEntry {
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
note?: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoStripItem {
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalSection {
|
||||||
|
body: string[];
|
||||||
|
bullets?: string[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
57
apps/website/tests/smoke/contact-legal.spec.ts
Normal file
57
apps/website/tests/smoke/contact-legal.spec.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectFooterLinks,
|
||||||
|
expectPrimaryNavigation,
|
||||||
|
expectShell,
|
||||||
|
openMobileNavigation,
|
||||||
|
visitPage,
|
||||||
|
} from './smoke-helpers';
|
||||||
|
|
||||||
|
const coreRoutes = ['/', '/product', '/solutions', '/security-trust', '/integrations', '/contact'] as const;
|
||||||
|
|
||||||
|
test('contact page qualifies the conversation and keeps legal links reachable', async ({ page }) => {
|
||||||
|
await visitPage(page, '/contact');
|
||||||
|
await expectShell(page, 'Start a qualified working session instead of a generic demo request.');
|
||||||
|
await expectPrimaryNavigation(page);
|
||||||
|
await expectFooterLinks(page);
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'Email the TenantAtlas team' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'Privacy' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'Terms' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legal, privacy, and terms routes are published and linked', async ({ page }) => {
|
||||||
|
await visitPage(page, '/legal');
|
||||||
|
await expectShell(page, 'Legal access should stay one click away from the contact path.');
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'Privacy' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'Terms' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await visitPage(page, '/privacy');
|
||||||
|
await expectShell(page, 'Public-site privacy overview for TenantAtlas inquiries.');
|
||||||
|
|
||||||
|
await visitPage(page, '/terms');
|
||||||
|
await expectShell(page, 'Website terms for the public TenantAtlas surface.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core pages keep contact and legal paths within reach', async ({ page }) => {
|
||||||
|
for (const path of coreRoutes) {
|
||||||
|
await visitPage(page, path);
|
||||||
|
await expectFooterLinks(page);
|
||||||
|
|
||||||
|
if (path !== '/contact') {
|
||||||
|
await expect(page.locator('main a[href="/contact"]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('mobile navigation', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('mobile menu exposes the published contact and legal paths', async ({ page }) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await openMobileNavigation(page);
|
||||||
|
await expect(page.getByRole('banner').getByRole('link', { name: /Contact/ }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Privacy' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Terms' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/website/tests/smoke/home-product.spec.ts
Normal file
39
apps/website/tests/smoke/home-product.spec.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expectFooterLinks,
|
||||||
|
expectPrimaryNavigation,
|
||||||
|
expectShell,
|
||||||
|
visitPage,
|
||||||
|
} from './smoke-helpers';
|
||||||
|
|
||||||
|
test('home explains the product category and exposes the next step', async ({ page }) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectShell(page, /TenantAtlas/);
|
||||||
|
await expectPrimaryNavigation(page);
|
||||||
|
await expectFooterLinks(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Governance of record for Microsoft tenant operations.' }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'See the product model' }).first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('main').getByRole('link', { name: 'Review the trust posture' }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product explains the connected operating model instead of a loose feature list', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/product');
|
||||||
|
await expectShell(page, 'One operating model for change history, drift visibility, and review readiness.');
|
||||||
|
await expectPrimaryNavigation(page);
|
||||||
|
await expectFooterLinks(page);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Connected governance model' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByRole('link', { name: 'See audience fit' }).first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('main')
|
||||||
|
.getByRole('link', { name: 'Talk through your current operating model' })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
45
apps/website/tests/smoke/smoke-helpers.ts
Normal file
45
apps/website/tests/smoke/smoke-helpers.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export const primaryNavigationLabels = [
|
||||||
|
'Product',
|
||||||
|
'Solutions',
|
||||||
|
'Security & Trust',
|
||||||
|
'Integrations',
|
||||||
|
'Contact',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const footerLabels = ['Legal', 'Privacy', 'Terms', 'Contact / Demo'] as const;
|
||||||
|
|
||||||
|
export async function visitPage(page: Page, path: string): Promise<void> {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page).toHaveURL(new RegExp(path === '/' ? '/?$' : `${path}$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectShell(page: Page, heading: string | RegExp): Promise<void> {
|
||||||
|
await expect(page.getByRole('banner')).toBeVisible();
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
await expect(page.getByRole('contentinfo')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: heading })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectPrimaryNavigation(page: Page): Promise<void> {
|
||||||
|
const header = page.getByRole('banner');
|
||||||
|
|
||||||
|
for (const label of primaryNavigationLabels) {
|
||||||
|
await expect(header.getByRole('link', { name: label })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFooterLinks(page: Page): Promise<void> {
|
||||||
|
for (const label of footerLabels) {
|
||||||
|
await expect(page.getByRole('contentinfo').getByRole('link', { name: label })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMobileNavigation(page: Page): Promise<void> {
|
||||||
|
const menuTrigger = page.getByLabel('Open navigation menu');
|
||||||
|
|
||||||
|
if (await menuTrigger.isVisible()) {
|
||||||
|
await menuTrigger.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user