DownloadDecisions
One short entry per significant choice: context, decision, trade-off. Add new entries at the bottom as the project evolves.
Schema snapshot method
Context: need a reliable, accurate schema representation for the running app.
Decision: introspect the live default connection directly (the app's real database as it currently exists). A specific connection can be requested via config/UI. If no connection is reachable, fall back to replaying migrations on a fresh in-memory SQLite connection and introspecting that, with a UI banner flagging the degraded mode.
Trade-off: requires a migrated database to be present for full fidelity; the SQLite fallback loses native types and can break on driver-specific migrations. Both beat static AST-parsing of migration files (unreliable against loops/conditionals/raw SQL), which stays rejected.
Fallback specifics: the replay runs all migration paths the Laravel migrator resolves (app + package migrations via loadMigrationsFrom), not just database/migrations, so it matches php artisan migrate. Replay is graceful per-migration ? a migration that fails on SQLite is skipped and recorded (surfaced in the fallback banner), never fatal; the snapshot reflects whatever replayed successfully.
Schema introspection tool
Context: need to read tables, columns, indexes, and foreign keys from a live connection (and from the SQLite fallback).
Decision: Laravel's native schema methods (Schema::getTables/getColumns/getIndexes/getForeignKeys). No doctrine/dbal.
Trade-off: DBAL has marginally richer introspection for exotic edge cases, but it is a heavy extra dependency that normalizes native types into its own abstract type system ? the opposite of the native-full-type decision. Native ships with Laravel 11+, returns native type/type_name, exposes composite index/FK columns and a primary-key flag directly, and works identically across mysql/pgsql/sqlite so the live and fallback paths share one code path. Laravel 12+ minimum means the native APIs are always available without compatibility guards.
Frontend diagram library
Context: need scrollable/zoomable ER diagrams without adding a JS build step to a PHP package.
Decision: Mermaid.js, rendered client-side from the JSON schema endpoint.
Trade-off: less visually customizable than React Flow/vis-network, but zero build tooling, which matters for a Composer package that shouldn't force a frontend toolchain on consumers.
Package name
Context: needed a short, memorable, collision-free name for a public Packagist package.
Decision: albertoarena/laravel-truss. A truss is a structural framework of connected members and nodes, matches the shape of an ER diagram closely. Checked against Packagist: truss/truss exists but is an inactive, unrelated, zero-download framework under a different vendor namespace, not a real collision.
Rejected: Lattice (taken by the established lattice-php/lattice React/Inertia package), Girder (disliked on reflection), Mosaic, Compass, Cairn, Vellum (not pursued once Truss checked out clean).
Minimum supported versions
Context: how wide a compatibility matrix to support.
Decision: Laravel 12+, PHP 8.3+, latest only.
Trade-off: simpler CI matrix and code (can use newer language features freely), at the cost of excluding users on older LTS Laravel versions.
Config scope
Decision: full-featured config from v1: excluded tables, route path, gate/authorization callback, cache TTL, per-connection settings, diagram styling. Not deferred to a later version.
Package skeleton
Decision: fresh skeleton using spatie/laravel-package-tools, standard Spatie-style layout. Not reusing the laravel-event-sourcing skeleton, since Truss has a different shape (no event sourcing involved).
Route architecture
Context: single server-rendered route vs. index page + JSON API endpoint.
Decision: index page + JSON endpoint (matches the index-page-and-JSON-endpoint dashboard pattern).
Trade-off: more moving parts up front (an API route, a loading state) versus a single server-rendered route, but makes connection switching and manual refresh trivial without a full page reload, which the config's per-connection support makes a real, not hypothetical, need.
Asset delivery: served from the package, Mermaid vendored, no CDN
Context: the frontend needs to load its ES modules, a stylesheet, and Mermaid. Two axes to decide: how our own assets reach the browser (publish to public/ vs. serve from the package) and where Mermaid comes from (CDN vs. self-hosted).
Decision ? serve assets from a gated package route. A GET {route_prefix}/assets/{file} route serves the files straight from the package: zero-config (no vendor:publish), and the delivery mechanism becomes Pest-testable. The {file} param is allow-listed by basename ? that mapping is also the traversal guard. The route lives inside the gated group, so unauthorized users get 404 on assets exactly as on the page; a public asset route would leak Truss's existence and undo the 404-on-denial decision. Assets are served with Cache-Control: private, max-age (gated ? per-user, not shared-cacheable). The earlier truss-assets publish tag is removed ? one delivery path.
Decision ? vendor Mermaid, self-host by default. A verbatim mermaid.min.js (MIT, ~3.4 MB) ships in the package and is served from the same route, so there is no CDN dependency out of the box and a strict CSP needs only script-src 'self'. diagram.mermaid_url (TRUSS_MERMAID_URL) is an escape hatch: set it to load Mermaid from a CDN or a custom copy; null (default) self-hosts.
Trade-off: +3.4 MB in the (dev-only) package and a manual bump on Mermaid releases, in exchange for a tool that works offline, out of the box, and under CSP ? which the user prioritised over package weight. Serving through PHP is slightly slower than a static file, but the assets are few, small (bar Mermaid), and browser-cached. CSP caveat: self-hosting fixes script-src, and moving the CSS out of the Blade into a served file keeps style-src 'self' for our own styles ? but Mermaid injects a <style> into the rendered SVG at runtime, so a strict policy still needs style-src 'self' 'unsafe-inline'. That is a Mermaid limitation, documented rather than worked around.
License & CI
Decision: MIT license. CI via GitHub Actions running Pest across the supported PHP/Laravel matrix.
Documentation approach
Decision: two in-repo doc surfaces kept in sync, README.md and docs/ (markdown), plus a separate public docs site.
Change (2026-07): the docs site was originally an in-repo website/ (Astro Starlight, deployed to GitHub Pages). It now lives in its own repo, albertoarena/laravel-truss-docs, published at trussphp.com and hosted on Netsons via a server-pull deploy. Its live demo fetches this package's shipped frontend from the latest release tag at build time, so it is release-gated: a frontend or docs change here reaches the site only after a release and a docs rebuild. Keeping the docs out of the package repo keeps the package labelled PHP and lets the site ship on its own cadence.
CLAUDE.md structure
Context: avoiding the common failure mode of a CLAUDE.md that grows until it degrades Claude's own performance.
Decision: root CLAUDE.md stays lean (overview, pinned stack versions, exact commands, always-true invariants that apply everywhere, pointers out). Heavy prose lives in docs/DESIGN.md, docs/INSTRUCTIONS.md, docs/DECISIONS.md. Layer-specific rules live in path-scoped rules under .claude/rules/ (introspection.md, frontend.md) with a paths: glob, so they auto-load only when a matching file is in play.
Change: this replaced an earlier nested src/Introspection/CLAUDE.md. A path-scoped rule is centralized and discoverable, and its glob can span non-adjacent paths (the layer plus its tests), which a nested file cannot. The model behind this split: root CLAUDE.md is loaded every turn, path rules are guaranteed for matched files, skills would be probabilistic, and docs/ is optional prose.
Note: these tiers are context, not enforcement. For an invariant that must never slip (the no-data guarantee, introspection purity), a hook is the only true guarantee; the text tiers state the rule but do not enforce it.
Table selection: server-side exclusions, client-side interaction
Context: excluded_tables filtering was described as client-side in one place and asserted server-side (never in the response body) in another.
Decision: split by nature. Permanent config excluded_tables are applied server-side ? removed from the API response so their structure never reaches the browser. Transient interactive filter and focus are applied client-side on the received JSON. The cached snapshot stays the full schema; exclusions are applied at serve time, so toggling excluded_tables needs no rebuild.
Trade-off: two filtering sites instead of one, but each matches its job ? server-side keeps excluded structure off the wire and the payload lean; client-side keeps interactive selection instant with no refetch. Resolves the earlier contradiction.
"No data" boundary: column defaults are structure
Context: the core promise is "no data exposed, ever ? never row contents." Column defaults (default: "pending", CURRENT_TIMESTAMP) are serialized in the schema, which sits right on the fuzzy edge of that promise.
Decision: the boundary is CREATE TABLE definition vs. table rows. Everything in the table definition ? including column defaults ? is structure and is in scope. Row contents are never read or exposed. Column defaults stay in the output.
Trade-off: a default can embed a sensitive literal (a hardcoded token set as a default), which is the one place structure can leak a value that feels data-like. Judged acceptable for v1 given how rare it is and how useful defaults are. A redact_defaults config escape hatch is deferred ? added only if someone asks, not built speculatively.
Authorization: fixed gate name, app-defined callback
Context: should the authorization ability name be configurable, or fixed?
Decision: fixed viewTruss gate. The package ships a default definition (allow in local only); the host app customizes access by redefining the viewTruss gate in its own service provider. There is no gate config key ? the name is not configurable.
Trade-off: slightly less flexible than a renamable ability, but matches the fixed-ability convention Laravel developers expect, and removes a real failure mode (a typo'd gate name silently missing ? lockout or open access). The only meaningful customization ? who may view ? already lives in the gate callback, not in a name.
Authorization: production-gated model
Context: Truss is meant to be safe to install in production and gated there, not just a local dev tool. That makes the authorization model a primary concern, not an afterthought, and raises three sub-decisions: when the gate is consulted, how a viewer is identified, and what a denial returns.
Decision ? gate only in non-local. The Authorize middleware consults viewTruss only when the environment is not local; local is unconditionally open (subject to truss.enabled). This keeps local development open and avoids the gotcha of a gate accidentally locking the author out of their own local environment. Access is two independent layers, both required: truss.enabled (off ? 404, routes act as if absent; defaults to local-only, so production is dark until TRUSS_ENABLED=true) and the viewTruss gate.
Decision ? 404 on denial, not 403. A denied gate returns 404, so the dashboard never confirms its own existence to someone who may not view it. enabled-off also returns 404; the two failure modes are indistinguishable from outside.
Decision ? an env-driven email allow-list as the shipped default gate. Truss is a package and cannot publish a gate stub into the app. So the ergonomic equivalent is config: the default viewTruss gate admits the emails in truss.authorization.allowed_emails (TRUSS_ALLOWED_EMAILS, comma-separated). This is the zero-code path for "gate production to these admins". It fails closed ? an empty list admits no one in non-local. A host needing role-based logic overrides the whole gate (fn (User $user) => $user->isAdmin()), and the list is then unused. This softens the earlier "no authorization config" stance, but the invariant that mattered still holds: the ability name is fixed and the callback is app-controlled; the email list is merely the default gate's data, not a renamable ability.
Decision ? a configurable auth-context middleware stack (truss.middleware, default ['web']). The gate can only identify the viewer if the request first passed through session/auth middleware. Without web (or an equivalent), $request->user() is null in production and the default gate denies everyone ? including legitimate admins. So the stack is load-bearing. It is config-driven for apps that authenticate via a custom guard or Sanctum. The fixed viewTruss guard is always appended after the configured stack and cannot be configured away ? the config controls the auth context, not whether authorization runs.
Trade-off: more moving parts than a single gate closure (a middleware key, an allow-list key, two failure layers), but each earns its place for the production-gated use case, and every misconfiguration fails closed (locks down), never open. Guests resolve to a null user and are denied; the shipped default uses a nullable $user = null so it can still be evaluated for a guest rather than being skipped.
Registration order: the package defines the default only if (! Gate::has('viewTruss')), but a host definition wins regardless of order ? package providers boot before app providers, so a host Gate::define('viewTruss', ?) overrides the default; the guard only avoids clobbering a host that defined it earlier (e.g. in register()).
Schema data model: composite-first keys, explicit primary key
Context: the initial shape modelled foreign keys as single-column and had no primary-key concept (PK was only implicit via an index). Composite PKs (pivots), composite FKs, and composite unique indexes are all real.
Decision: model keys composite-first. Table.primaryKey is a string[] (empty when none) hoisted out of the index list; Index keeps columns[] + unique (no primary flag, since the PK is hoisted); ForeignKey carries columns[], referencesColumns[], plus name, onUpdate, onDelete. Per-column PK/FK badges are derived at render time from primaryKey and foreign_keys[].columns, not stored on columns.
Trade-off: a small transform away from the raw native output (hoisting the primary index, keeping FK actions we don't render yet), in exchange for correctly representing composite keys, a single source of truth for PK/FK, and no retrofit later. Native introspection supplies all of this directly.
Column types: native full type, optional Laravel-style label
Context: introspection can only see native DB types (varchar(255)), not the Laravel migration verbs (string) that produced them. Reverse-mapping native ? Laravel is inference and is lossy/ambiguous (tinyint(1) = boolean or small int?).
Decision: store and display the native full type as the source of truth, by default. A UI toggle offers an optional best-effort Laravel-style short label, computed in the presentation layer and never written back into the schema output. Default mode is config-driven (diagram.type_labels, default native).
Trade-off: native types differ across drivers and the SQLite fallback (already flagged by the fallback banner), but they are truthful and inference-free ? consistent with rejecting AST-parsing for the same reason. The Laravel label is convenience only, explicitly best-effort.
Large-schema rendering: filter + focus share one pipeline
Context: diagrams must stay usable at 50+ tables from v1. Rendering every table at once is both slow (dagre layout is superlinear in nodes/edges) and illegible.
Decision: insert a single table-selection step between the cached JSON and the Mermaid definition. Config exclusions, interactive filtering, and focus mode are all reducers over the same set, feeding the same MermaidDefinitionGenerator. Focus mode (a table + its FK neighbours, depth-1 default) is promoted from v2 to v1 as the primary large-schema mechanism, implemented as reduce-and-re-render (regenerate the definition over the subset) rather than dim-in-place, so dagre lays out only the neighbourhood.
Trade-off: focus/filter add interactive UI and state up front, but because they are one shared operation with different predicates, the marginal cost over building either alone is small, and it is what makes the tool usable on real (non-toy) schemas. Mermaid's maxTextSize/maxEdges guards are raised so large schemas render rather than error.
Canvas zoom: no will-change: transform
Context: the canvas holding the rendered SVG is pan/zoomed with a CSS translate(x, y) scale(zoom). The intuitive "make transforms smooth" hint, will-change: transform, was on the #truss-canvas rule.
Decision: drop will-change: transform. That hint pins the canvas to a GPU layer rasterised once at promotion scale and then bitmap-upscaled, so text visibly blurs past ~1x (most obvious near the 800% ceiling). Without it the browser re-rasterises the vector SVG on each scale change, keeping deep zoom crisp.
Trade-off: a re-raster now happens per zoom step, but panning only changes translate (the raster stays valid), so pan stays cheap and the cost lands exactly where a fresh crisp raster is wanted. Guarded by a source-contract test (tests/js/canvas-css.test.js) so the hint is not re-added as an "optimisation".
Introspection scope: the connection's own schema only
Context: since Laravel 12, Schema::getTables() with no argument lists every non-system schema the connection can reach, not just the one the connection points at. On a shared server (a local MySQL hosting many project databases, or a Postgres cluster) that means the snapshot would enumerate tables from unrelated databases. The per-table calls (getColumns, getIndexes, getForeignKeys) resolve against the current schema instead, so those foreign tables also came back empty, and names duplicated across schemas collided in the snapshot.
Decision: pass the current schema to the listing: getTables($builder->getCurrentSchemaName()). That accessor is driver-correct on its own, so no driver branching is needed: it returns the database name on MySQL, the search-path schema on PostgreSQL, and 'main' on SQLite. This keeps the table list consistent with the per-table introspection, which already scoped to the current schema. It also upholds the "no data, structure only" promise by not exposing the structure of every other database the credentials can reach.
Trade-off: multi-schema introspection is deliberately out of scope for now. Supporting it would mean carrying the schema value on Table, schema-qualifying the per-table calls, and making it opt-in rather than the default; deferred to a later change. The regression is pinned by a Feature test that attaches a second SQLite schema and asserts its tables never leak into the snapshot.
Schema diff: durable baseline on disk
Context: the schema-diff feature ("what changed since the last migration") needs a previous schema to compare the current one against. Everything else Truss stores is the cached snapshot, which is derived, disposable, and TTL'd, safe to keep in the cache because a miss just rebuilds it from live introspection. The baseline is different: once a migration has run, the pre-migration schema exists nowhere else, so it cannot be rebuilt. This asymmetry is the whole design problem.
Decision: persist the baseline as a structure-only JSON file on a configurable Laravel disk (truss.diff.disk, default disk otherwise) at truss/baselines/{connection}.json, via a small BaselineStore. Caching is the right tool for the current snapshot because it is reconstructible, and the wrong tool for the baseline because it is not: a cache clear or a deploy would silently empty the diff exactly when it is most wanted (right after a production migration). The diff engine itself (SchemaDiffer) is pure PHP over two arrays, feeding both surfaces, the dashboard "Changes" panel (via a diff field on the schema API) and a php artisan truss:diff command.
Capture timing: the baseline is captured only on MigrationsEnded, from the snapshot already in the cache, before the rebuild overwrites it. Never on an ordinary cache-miss rebuild, otherwise "previous" would drift to mean "whenever the TTL last expired", which is meaningless. So a diff always reflects exactly the last migration.
Opt-out: because this is the package's first write to the host filesystem, it is opt-out via truss.diff.enabled (default true). When false the check short-circuits before any filesystem touch: no baseline is captured, nothing is written, the API returns diff: null, the dashboard button is hidden, and truss:diff reports the feature is off. A host that does not want files on disk gets a hard guarantee, not a best effort.
Trade-off: matching is by name, so a rename reads as a remove plus an add (no rename heuristic in v1); a single baseline per connection, not a history; and the diff is best-effort in that deleting the baseline file (or a fresh install before any migration) yields an empty diff until the next migration re-seeds it. Removed tables are list-only in the diagram (they no longer exist in the current schema, so ghosting them would fight the Mermaid layout). Structure only holds by construction: both inputs are already structure-only snapshots.
Schema doctor: deterministic structural review
Context: Truss already builds a structure-only snapshot, so the natural next step is to review it for problems, not just render it. The constraint is the package's identity: structure only, deterministic, no network, and no AI at any point, including for message wording. The public framing is "structural review", never "performance analysis", because structure alone cannot know selectivity or query patterns and must not imply it does.
Decision: truss:doctor (aliased truss:check) runs a set of pure rules over the snapshot and reports findings, and can fail CI. The engine mirrors src/Diff/: rules are pure (a serialized snapshot and a connection name in, Findings out), a DoctorRunner owns the config-driven concerns (per-code severity overrides, ignore patterns), and a RuleRegistry resolves the active set. Rules live under src/Doctor/Rules/{Integrity,Index,Type}.
Confidence separate from severity: each rule declares a confidence (high or heuristic) independent of its severity. The recommended preset runs high-confidence rules only, so heuristic, name-based rules (a *_id with no foreign key, money-looking floats) stay quiet by default while still firing at their real severity under strict or when explicitly enabled. This keeps severity honest instead of demoting a genuinely-bad-but-uncertain finding to "info" just to hide it.
Fingerprint and naming: a finding's identity is a hash of code, connection, table, and column, never the message, so re-wording never invalidates a suppression and the identity is order-independent. The word "baseline" is reserved for the schema-diff snapshot; the doctor's accepted-findings file (Phase 2) is a separate "suppression" file, to avoid confusing the two.
Engine awareness: a rule can adapt to the driver, which the command injects into the snapshot envelope at run time (the cache does not store it). IDX-001 (unindexed foreign key) is an error on PostgreSQL and SQLite, which do not auto-index foreign keys, and only info on MySQL and MariaDB, which do.
Scope: Phase 1 ships the engine and thirteen rules (integrity, index, type), console and JSON output, presets, and exit codes. Suppression files, CI formatters, more rules, and fix stubs are later phases. No new runtime dependency, and no network call anywhere.
In the dashboard: the findings also surface in the browser. Rather than a second endpoint, the doctor report rides the existing /api/schema response as a doctor key, mirroring how the schema diff rides the same response. A DoctorReport service assembles and runs the doctor for a connection, and both the command and the controller go through it, so the CLI and the dashboard cannot drift. Excluded tables are filtered before the doctor runs, so an excluded table can never surface a finding, and the whole thing is gated by the viewTruss gate like the rest of the dashboard. Two config switches keep it opt-outable without touching the CLI: truss.doctor.dashboard (send the payload and show the panel at all) and truss.doctor.flag_tables (badge tables with findings on the diagram even when the panel is closed). Still structure only, no row data, no network call.
Schema export: server-side, deterministic, structure-only
Context: Truss already generated DBML, JSON, CSV, Markdown, and Mermaid, but only in the browser, so only a human with the dashboard open could get them. A CI job could not commit an up-to-date schema file or fail on drift, and a script or tool could not read the snapshot. The gap was access, not format.
Decision: php artisan truss:export produces the same structural formats from PHP. It writes to stdout by default (pipeable for CI and tooling) and to a file with --output. A new src/Export/ layer holds one Generator per format plus a SchemaExporter service that filters and orders the tables and dispatches to the generator. The generators live in src/Export/, not src/Introspection/, because they are presentation: the introspection layer stays pure of render concerns.
Deterministic by construction: the exporter imposes a canonical order (tables alphabetical, foreign keys by source column, indexes by name; columns and primary-key order left as-is because they carry meaning) and nothing time-, version-, or host-dependent enters the output. The same schema always produces the same bytes, which is what makes --check meaningful.
--check is a first-class feature, not a docs recipe. truss:export --output=path --check regenerates the export, compares it to the file, writes nothing, and exits non-zero on drift. More robust than shelling diff (no trailing-newline or path pitfalls) and it is the headline CI use case: fail the build when a committed schema file goes stale. It requires --output; there is nothing to compare against otherwise.
Exit codes mirror diff and the doctor: 0 written or up to date, 1 --check found drift (the one actionable non-error signal, so CI can tell "stale" from "broken"), 2 a usage or runtime error (unknown format, unmanaged/failed connection, unwritable path, --check without --output, or no tables matched the filters).
One artifact, one shape: the command normalises to exactly one trailing newline and uses that identical string for stdout, the file write, and the --check comparison, so truss:export > file and truss:export --output=file --check round-trip cleanly.
Config excluded_tables always wins over --tables. You cannot filter your way to a config-excluded table, so the export can never widen the structure surface beyond what the dashboard already exposes. The command reuses the same global-plus-per-connection exclusion merge and the managedConnections() allow-list the schema endpoint uses.
Temporary, test-guarded duplication. The browser download still uses the JS generators until a later route swap, so DBML, Markdown, and Mermaid now exist in both PHP and JS. Rather than let them drift, both are pinned to the same golden fixtures under tests/Fixtures/export/: a Pest test asserts the PHP generators match them and a Vitest test asserts the JS generators match the same files, so the two are byte-identical while they coexist. JSON and CSV are whole-schema server shapes with no comparable browser counterpart (the browser exports a single table), so they are covered by their own PHP golden tests. The canary test seeds a real row with a sentinel string and asserts it appears in no format, the executable form of the structure-only promise. Structure only, no row data, no network call.
Theming: config-driven custom palette, served as a stylesheet
Context: the whole dashboard, chrome and diagram alike, is already painted from one layer of CSS custom properties (--bp-*) with light and dark value sets, and the diagram text already resolves to var(--bp-mono). Hosts want the diagram to match the app Truss is embedded in. The constraints are the package's CSP posture (style-src 'self', script-src 'self', no CDN, no build step) and the config-is-source-of-truth rule.
Decision: a new truss.theme config block exposes a small set of semantic colour knobs (accent, accent-secondary, background, surface, surface-alt, text, muted, border) plus two font-family knobs (mono, sans). A ThemeStylesheet service maps each knob onto the internal --bp-* tokens and emits only the overridden ones, under the same :root / prefers-color-scheme / data-theme selectors the defaults use, so the light/dark toggle keeps working and a few values re-skin coherently. A gated GET {prefix}/theme.css route (ThemeController) serves it as a same-origin stylesheet, linked after the base sheet and only when a theme is configured, so a default install is byte-identical (no extra request) and the CSP is unchanged (no inline styles). Fonts are family names only; the package serves no host font files (a font_url opt-in, like mermaid_url, is deferred). The vestigial, unused truss.diagram.theme key is removed.
Semantic knobs over raw tokens: the knob names are the public, stable contract; the --bp-* targets stay private and refactorable. One knob may drive several related tokens (e.g. accent sets both --bp-ink and --bp-focus-border) with the same value, so a brand match needs five to eight values, not thirty-four. Raw per-token access is a possible later advanced layer, not Phase 1.
Security: config values are written into a CSS response, so each is validated against a strict allow-list first (hex / rgb / rgba / hsl / hsla / colour-keyword for colours; a font-family charset for fonts). Anything that could break out of the declaration (;, }, @, a comment, url(, var(, a newline) is dropped, and an unknown knob emits nothing, so a bad or hostile value degrades to the default and can never break or inject the sheet. This validator is the most-tested unit of the feature.
Trade-off: semantic knobs cover the common brand-match need with a friendly, stable API but hide granular control, and a saturated custom accent can sit against a default-tinted selection until derived tints are added later. Contrast is the host's responsibility (a custom palette can fail accessibility); the shipped default and the demo themes pass WCAG AA in both modes. Pure presentation: no introspection, snapshot, or data path is touched, so the structure-only guarantee is unaffected.
|