@if (!pullRequest)
@else
.github/workflows/ci.ymlmodified+9-3
app/Models/Project.tsmodified+6-0
app/Support/read-key.tsadded+104-0
app/Support/request-auth.tsmodified+85-0
config/ui.tsmodified+5-12
database/migrations/0000000004-create-users-table.sqlmodified+13-1
database/migrations/0000000005-create-subscriptions-table.sqlmodified+6-1
database/migrations/0000000008-create-email_suppressions-table.sqlmodified+6-1
database/migrations/0000000009-alter-password_resets-add-expires_at.sqlmodified+6-1
database/migrations/0000000017-auto-misc.sqlmodified+46-21
database/migrations/0000000024-alter-projects-read-key.sqladded+25-0
database/migrations/0000000025-create-subscriber-tables.sqladded+58-0
database/seeders/OwnerSeeder.tsadded+219-0
package.jsonmodified+3-3
resources/assets/scripts/site-mode.jsmodified+32-0
resources/layouts/marketing.stxmodified+4-1
resources/partials/Breadcrumbs.stxmodified+25-46
resources/views/account.stxmodified+25-6
resources/views/analytics.stxmodified+10-132
resources/views/dashboard.stxmodified+35-15
resources/views/docs.stxmodified+3-3
resources/views/fix.stxmodified+2-2
resources/views/forgot-password.stxmodified+2-2
resources/views/index.stxmodified+1-1
resources/views/login.stxmodified+4-4
resources/views/pricing.stxmodified+1-1
resources/views/projects/index.stxmodified+17-4
resources/views/projects/new.stxmodified+1-1
resources/views/register.stxmodified+3-3
resources/views/reset-password.stxmodified+2-2
resources/views/settings.stxmodified+201-30
routes/logs.tsmodified+15-12
routes/projects.tsmodified+42-0
scripts/smoke-deployment.tsmodified+86-6
scripts/validate-ui.tsmodified+138-5
scripts/validate-visual.tsmodified+62-3
tests/unit/auth-chrome.test.tsadded+141-0
No such pull request
loghqorg/loghq has no pull request numbered 17.
also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
54 files
+4252
-1353
| @@ -96,9 +96,15 @@ jobs: | ||
| 96 | 96 | # the count down, then delete `continue-on-error` and add the grep that |
| 97 | 97 | # fails on anything but "0 error(s), 0 warning(s)". |
| 98 | 98 | # |
| 99 | # When that happens, note that --lib must be a SINGLE file: the checker | |
| 100 | # does not read ambient types from tsconfig `include`, and repeating the | |
| 101 | # flag silently drops all but the last (stacksjs/stx#1926). | |
| 99 | # When that happens: --lib is required, because the checker does not read | |
| 100 | # ambient types from tsconfig `include`. It may be REPEATED, so the | |
| 101 | # declarations do not have to be crammed into one file. | |
| 102 | # stacksjs/stx#1926, where all but the last flag were silently dropped, | |
| 103 | # is fixed. Verified on this repo's own installed stx (0.2.532) with two | |
| 104 | # .d.ts files each declaring one global: both passed resolves both, and | |
| 105 | # passing only the first fails with `Cannot find name 'betaGlobal'`, | |
| 106 | # which is the control that proves the checker ran at all. | |
| 107 | # stacksjs/analyticshq passes six --lib flags in CI today. | |
| 102 | 108 | - name: Typecheck .stx (advisory) |
| 103 | 109 | continue-on-error: true |
| 104 | 110 | run: bunx --bun stx typecheck 'resources/**/*.stx' |
| @@ -27,6 +27,12 @@ export default defineModel({ | ||
| 27 | 27 | // Public ingest key: the SDK sends it as `X-LogHQ-Key` so reports can only |
| 28 | 28 | // be written to a project that presents its key (revocable, not a secret). |
| 29 | 29 | ingest_key: { fillable: true, validation: { rule: schema.string().optional() }, factory: () => globalThis.crypto.randomUUID().replace(/-/g, '') }, |
| 30 | // Secret read key: a machine presents it as `Authorization: Bearer` to read | |
| 31 | // this project without a person's credentials (#14). Not fillable and with | |
| 32 | // no factory — it is issued on demand from project settings, and revoking | |
| 33 | // it is setting it back to null. See app/Support/read-key.ts. | |
| 34 | read_key: { fillable: false, validation: { rule: schema.string().optional() } }, | |
| 35 | read_key_last_used_at: { fillable: false, validation: { rule: schema.string().optional() } }, | |
| 30 | 36 | owner_id: { fillable: true, validation: { rule: schema.number().optional() } }, |
| 31 | 37 | is_active: { fillable: true, validation: { rule: schema.boolean().optional() }, factory: () => true }, |
| 32 | 38 | }, |
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * A project's read key: the credential a machine presents to read that | |
| 3 | * project's logs. | |
| 4 | * | |
| 5 | * The ingest key next to it is deliberately public — it ships inside client | |
| 6 | * bundles, it only permits writes to one project, and `app/Errors/ingest.ts` | |
| 7 | * compares it with `===` because nothing is lost by leaking it. A read key is | |
| 8 | * the opposite on both counts: it returns data, and it is meant to sit in a | |
| 9 | * server's configuration and nowhere else. So it is longer, it is compared in | |
| 10 | * constant time, and it is never accepted on an ingest route. | |
| 11 | * | |
| 12 | * It authorises exactly one project. There is no account behind it, so it | |
| 13 | * outlives the person who created it and carries none of their scope — which | |
| 14 | * is the whole reason it exists (loghqorg/loghq#14): a Stacks dashboard | |
| 15 | * reading logs server-to-server should not be holding somebody's session. | |
| 16 | */ | |
| 17 | ||
| 18 | /** | |
| 19 | * `lhq_read_` then 256 bits of hex. | |
| 20 | * | |
| 21 | * The prefix is doing real work. It tells a read key apart from a user's | |
| 22 | * bearer token before either is verified, so a read key is never handed to | |
| 23 | * `Auth.getUserFromToken`; and it gives secret scanners something to match, so | |
| 24 | * a key pasted into a repository can be found and revoked. | |
| 25 | */ | |
| 26 | const PREFIX = 'lhq_read_' | |
| 27 | const BODY = /^[0-9a-f]{64}$/ | |
| 28 | ||
| 29 | export function newReadKey(): string { | |
| 30 | const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32)) | |
| 31 | return PREFIX + Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') | |
| 32 | } | |
| 33 | ||
| 34 | /** Whether a presented credential is shaped like a read key at all. */ | |
| 35 | export function looksLikeReadKey(value: unknown): value is string { | |
| 36 | return typeof value === 'string' && value.startsWith(PREFIX) && BODY.test(value.slice(PREFIX.length)) | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Equal, in time that does not depend on how much of the key is right. | |
| 41 | * | |
| 42 | * `===` on a string short-circuits at the first differing byte, which leaks | |
| 43 | * the length of a correct prefix to anyone who can measure the response. That | |
| 44 | * is a real attack on a secret an attacker can guess at repeatedly, and not | |
| 45 | * one on the public ingest key, which is why only this side does it. | |
| 46 | */ | |
| 47 | export function sameKey(presented: string, stored: string): boolean { | |
| 48 | if (presented.length !== stored.length) | |
| 49 | return false | |
| 50 | let diff = 0 | |
| 51 | for (let i = 0; i < presented.length; i++) | |
| 52 | diff |= presented.charCodeAt(i) ^ stored.charCodeAt(i) | |
| 53 | return diff === 0 | |
| 54 | } | |
| 55 | ||
| 56 | export interface ReadKeyProject { | |
| 57 | id: string | |
| 58 | read_key?: string | null | |
| 59 | is_active?: boolean | number | null | |
| 60 | } | |
| 61 | ||
| 62 | export type ReadAuthorization = | |
| 63 | | { ok: true } | |
| 64 | | { ok: false, status: 401 | 403 | 404, error: string } | |
| 65 | ||
| 66 | /** | |
| 67 | * Whether this key may read this project. | |
| 68 | * | |
| 69 | * A project nobody can name is a 404 rather than a 401: answering "that | |
| 70 | * project exists, your key is wrong" tells an unauthenticated caller which | |
| 71 | * project ids are real. | |
| 72 | */ | |
| 73 | export function authorizeRead(project: ReadKeyProject | null | undefined, presented: unknown): ReadAuthorization { | |
| 74 | if (!project) | |
| 75 | return { ok: false, status: 404, error: 'unknown project' } | |
| 76 | if (project.is_active === false || project.is_active === 0) | |
| 77 | return { ok: false, status: 403, error: 'project inactive' } | |
| 78 | // No key issued is not the same as "any key will do": a project without one | |
| 79 | // must refuse every presented key, including an empty one. | |
| 80 | if (!looksLikeReadKey(project.read_key)) | |
| 81 | return { ok: false, status: 403, error: 'project has no read key' } | |
| 82 | if (!looksLikeReadKey(presented) || !sameKey(presented, project.read_key)) | |
| 83 | return { ok: false, status: 401, error: 'invalid read key' } | |
| 84 | return { ok: true } | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * How often a key's last-used stamp is written. | |
| 89 | * | |
| 90 | * The stamp is there so somebody can see whether a key is still in use before | |
| 91 | * revoking it, which a minute's resolution answers. Writing on every request | |
| 92 | * would put an UPDATE in front of every read of a log stream that a dashboard | |
| 93 | * polls. | |
| 94 | */ | |
| 95 | export const LAST_USED_RESOLUTION_MS = 60_000 | |
| 96 | ||
| 97 | export function shouldRecordUse(lastUsedAt: string | Date | null | undefined, now: Date): boolean { | |
| 98 | if (!lastUsedAt) | |
| 99 | return true | |
| 100 | const previous = lastUsedAt instanceof Date ? lastUsedAt.getTime() : Date.parse(String(lastUsedAt)) | |
| 101 | if (Number.isNaN(previous)) | |
| 102 | return true | |
| 103 | return now.getTime() - previous >= LAST_USED_RESOLUTION_MS | |
| 104 | } | |
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | import { Auth } from '@stacksjs/auth' |
| 16 | 16 | import { config } from '@stacksjs/config' |
| 17 | import { db } from '@stacksjs/database' | |
| 18 | import { authorizeRead, looksLikeReadKey, shouldRecordUse } from './read-key' | |
| 17 | 19 | |
| 18 | 20 | export async function userFromRequest(request: any): Promise<any | null> { |
| 19 | 21 | const authHeader = request.headers?.get?.('authorization') ?? '' |
| @@ -37,3 +39,86 @@ export async function userFromRequest(request: any): Promise<any | null> { | ||
| 37 | 39 | return null |
| 38 | 40 | } |
| 39 | 41 | } |
| 42 | ||
| 43 | /** | |
| 44 | * The bearer a request presents, whatever carries it. | |
| 45 | * | |
| 46 | * Split out of `userFromRequest` so a read key can be recognised before it is | |
| 47 | * sent anywhere near `Auth.getUserFromToken`: the two credentials arrive in | |
| 48 | * the same header and only their shape tells them apart. | |
| 49 | */ | |
| 50 | function bearerFrom(request: any): string { | |
| 51 | const authHeader = request.headers?.get?.('authorization') ?? '' | |
| 52 | const header = request.bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '') | |
| 53 | if (header) | |
| 54 | return header | |
| 55 | const cookie = request.headers?.get?.('cookie') ?? '' | |
| 56 | const name = (config.auth?.defaultTokenName || 'auth-token').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 57 | const m = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`)) | |
| 58 | return m ? decodeURIComponent(m[1]) : '' | |
| 59 | } | |
| 60 | ||
| 61 | export type ProjectReadAccess = | |
| 62 | | { ok: true, as: 'user', user: any } | |
| 63 | | { ok: true, as: 'key' } | |
| 64 | | { ok: false, status: 401 | 403 | 404, error: string } | |
| 65 | ||
| 66 | /** | |
| 67 | * Who may read this project: the person signed in, or a key issued for it. | |
| 68 | * | |
| 69 | * Order matters. A read key is checked first when one is presented, because a | |
| 70 | * key is scoped to a single project and answering it does not need a user | |
| 71 | * lookup at all. Anything that is not shaped like a read key falls through to | |
| 72 | * the existing user path unchanged, so the dashboard's cookie and a person's | |
| 73 | * bearer token behave exactly as they did. | |
| 74 | * | |
| 75 | * A key for a different project answers 404 rather than 403, matching what | |
| 76 | * `ownsProject` already does: a caller who cannot read a project should not be | |
| 77 | * able to learn that it exists. | |
| 78 | */ | |
| 79 | export async function projectReadAccess(request: any, projectId: string, ownsProject: (user: any, id: string) => Promise<boolean>): Promise<ProjectReadAccess> { | |
| 80 | const presented = bearerFrom(request) | |
| 81 | ||
| 82 | if (looksLikeReadKey(presented)) { | |
| 83 | // db.unsafe answers an untyped row, so the columns are narrowed here rather | |
| 84 | // than asserted: authorizeRead decides who may read a project and should be | |
| 85 | // reading values it can see the shape of. | |
| 86 | const found = (await db.unsafe( | |
| 87 | 'SELECT id, read_key, read_key_last_used_at, is_active FROM projects WHERE id = $1 LIMIT 1', | |
| 88 | [String(projectId)], | |
| 89 | ))?.[0] | |
| 90 | const row = found | |
| 91 | ? { | |
| 92 | id: String(found.id), | |
| 93 | read_key: found.read_key == null ? null : String(found.read_key), | |
| 94 | read_key_last_used_at: found.read_key_last_used_at == null ? null : String(found.read_key_last_used_at), | |
| 95 | is_active: found.is_active as boolean | number | null, | |
| 96 | } | |
| 97 | : null | |
| 98 | const verdict = authorizeRead(row, presented) | |
| 99 | // A key that is valid for some other project must not reveal that this one | |
| 100 | // exists, so its refusal is the same 404 an unknown id gets. | |
| 101 | if (!verdict.ok) | |
| 102 | return verdict.status === 401 ? { ok: false, status: 404, error: 'not found' } : verdict | |
| 103 | if (row) | |
| 104 | await recordReadKeyUse(row) | |
| 105 | return { ok: true, as: 'key' } | |
| 106 | } | |
| 107 | ||
| 108 | const user = await userFromRequest(request) | |
| 109 | if (!user) | |
| 110 | return { ok: false, status: 401, error: 'unauthorized' } | |
| 111 | if (!(await ownsProject(user, projectId))) | |
| 112 | return { ok: false, status: 404, error: 'not found' } | |
| 113 | return { ok: true, as: 'user', user } | |
| 114 | } | |
| 115 | ||
| 116 | /** Stamp the key as used, at most once a minute — see LAST_USED_RESOLUTION_MS. */ | |
| 117 | async function recordReadKeyUse(project: { id: string, read_key_last_used_at?: string | null }): Promise<void> { | |
| 118 | const now = new Date() | |
| 119 | if (!shouldRecordUse(project.read_key_last_used_at, now)) | |
| 120 | return | |
| 121 | // Never let the stamp fail a read: it is an aid to revoking a key, not part | |
| 122 | // of authorising one. | |
| 123 | await db.unsafe('UPDATE projects SET read_key_last_used_at = $1 WHERE id = $2', [now.toISOString(), String(project.id)]).catch(() => {}) | |
| 124 | } | |
bun.lockmodified+21-11
| @@ -19,9 +19,9 @@ | ||
| 19 | 19 | }, |
| 20 | 20 | }, |
| 21 | 21 | "overrides": { |
| 22 | "@stacksjs/components": "0.2.401", | |
| 23 | "@stacksjs/stx": "0.2.401", | |
| 24 | "bun-plugin-stx": "0.2.401", | |
| 22 | "@stacksjs/components": "0.2.532", | |
| 23 | "@stacksjs/stx": "0.2.532", | |
| 24 | "bun-plugin-stx": "0.2.532", | |
| 25 | 25 | }, |
| 26 | 26 | "packages": { |
| 27 | 27 | "@bughq/sdk": ["@bughq/sdk@0.2.2", "", {}, "sha512-OySn397nSGHmFv4qu8/HUhX25sb+ORKCk94yOt/OR1vymScXPJPAA5eV9WcxrnzVUPvNOgEhp4lfEDm8MVS8Kw=="], |
| @@ -96,7 +96,7 @@ | ||
| 96 | 96 | |
| 97 | 97 | "@stacksjs/commerce": ["@stacksjs/commerce@0.75.79", "", { "dependencies": { "@stacksjs/cache": "0.75.79", "@stacksjs/config": "0.75.79", "@stacksjs/database": "0.75.79", "@stacksjs/env": "0.75.79", "@stacksjs/error-handling": "0.75.79", "@stacksjs/events": "0.75.79", "@stacksjs/logging": "0.75.79", "@stacksjs/orm": "0.75.79", "@stacksjs/payments": "0.75.79", "@stacksjs/realtime": "0.75.79", "@stacksjs/strings": "0.75.79", "ts-spreadsheets": "^0.2.0" } }, "sha512-yI9zltR+QUk5tOSF3/LadPxqdK0f5Bmj2eazMUH1dY5X3wpjqzHJMqcwtcFf3yfqsQXknTaKAXU5EeVjw6w5aQ=="], |
| 98 | 98 | |
| 99 | "@stacksjs/components": ["@stacksjs/components@0.2.401", "", { "dependencies": { "@iconify-json/f7": "^1.2.2", "@stacksjs/stx": "0.2.401", "@stacksjs/ts-css": "^0.3.2", "bun-plugin-stx": "0.2.401", "ts-syntax-highlighter": "^0.2.17" } }, "sha512-NqWBHU83zNEGsOHmHwVFOR4N97pteXqpCE5xcIqejeCXGNKpD/ecapG62GbPC+rvPRsHNRwKsOtMPMwObIMLMw=="], | |
| 99 | "@stacksjs/components": ["@stacksjs/components@0.2.532", "", { "dependencies": { "@iconify-json/f7": "^1.2.2", "@stacksjs/stx": "0.2.532", "@stacksjs/ts-css": "^0.3.11", "bun-plugin-stx": "0.2.532", "ts-syntax-highlighter": "^0.2.17", "ts-video-player": "^0.1.9" } }, "sha512-3XTW2xK+mKc0jqsWjXjZzEaPds1mr+XXrwYl54nhF+gU77YtR2dD30h7kvKecCH7kCD07Df3tpFttQiQveNgXA=="], | |
| 100 | 100 | |
| 101 | 101 | "@stacksjs/composables": ["@stacksjs/composables@0.75.79", "", { "dependencies": { "@stacksjs/datetime": "0.75.79", "@stacksjs/stx": "^0.2.378" } }, "sha512-ZPVfcfLqgyexCYmyW7ignmeS+d6fH7S98gWU+0IGGT9NsAJ2tLZ3bafzKp1JinBQvDQxvpbBbrWFsc7n6ZXWbw=="], |
| 102 | 102 | |
| @@ -226,7 +226,7 @@ | ||
| 226 | 226 | |
| 227 | 227 | "@stacksjs/strings": ["@stacksjs/strings@0.75.79", "", {}, "sha512-u5WxVtwM48aMcgBJL3BTOoac/lyKhnfZGzqMjBh+wdR/GDUbwO++6uhBH8GXZwQttgh5OMhvTAgtkyLl4AITkA=="], |
| 228 | 228 | |
| 229 | "@stacksjs/stx": ["@stacksjs/stx@0.2.401", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.401", "@stacksjs/sanitizer": "0.2.401", "@stacksjs/ts-css": "^0.3.2", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.401", "bunfig": "^0.15.19", "stx-router": "0.2.401", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-WMPMaBa/Pf6izmJkwMwj41omsBUBSgVy6RKakWpOZ1XBvTn0yPXrWAh8TZ0k6vBWx9ir+jcg87jk/t7onqBehw=="], | |
| 229 | "@stacksjs/stx": ["@stacksjs/stx@0.2.532", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.532", "@stacksjs/sanitizer": "0.2.532", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.532", "bunfig": "^0.15.19", "stx-router": "0.2.532", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-ScKb7Jai6ErVs/nLFupgjGf6Ms4jRIqqi3QfRJ+ZZJALuOoqSHf/JLyyDiTywsQdsn1pnH3HVSuIuFbHwh5hTA=="], | |
| 230 | 230 | |
| 231 | 231 | "@stacksjs/testing": ["@stacksjs/testing@0.75.79", "", { "dependencies": { "@stacksjs/auth": "0.75.79", "@stacksjs/cache": "0.75.79", "@stacksjs/config": "0.75.79", "@stacksjs/database": "0.75.79", "@stacksjs/email": "0.75.79", "@stacksjs/events": "0.75.79", "@stacksjs/path": "0.75.79", "@stacksjs/router": "0.75.79", "@stacksjs/storage": "0.75.79", "@stacksjs/strings": "0.75.79", "@stacksjs/ts-cloud": "^0.16.40", "very-happy-dom": "^0.3.5" } }, "sha512-DA7KWv8ZZ+kXqXSxGGwsudCVreVLMfqft1asYRo689NCOpn+Mw+pUR3L2JInyMsASek1JwRnjnUj747MzEhibg=="], |
| 232 | 232 | |
| @@ -244,7 +244,7 @@ | ||
| 244 | 244 | |
| 245 | 245 | "@stacksjs/ts-cloud": ["@stacksjs/ts-cloud@0.16.40", "", { "dependencies": { "@stacksjs/ts-xml": "^0.1.2", "@ts-cloud/aws-types": "0.16.40", "@ts-cloud/core": "0.16.40", "hash-wasm": "^4.12.0" }, "bin": { "cloud": "./dist/bin/cli.js" } }, "sha512-q2km71gzbBUO6aSvJci2BMkNVXjzv0uiUl1K65QautSov9cULz+nV0Mpy4meHYkRqSpq9SUTjU28t9zJiqDesA=="], |
| 246 | 246 | |
| 247 | "@stacksjs/ts-css": ["@stacksjs/ts-css@0.3.8", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "bunfig": "^0.15.17" }, "bin": { "cssx": "./dist/bin/cssx.js", "ts-css": "./dist/bin/cli.js" } }, "sha512-Hsbw5zlqMzaRTUJy/lRr7DcPvS7OgbxokTl+EfHeq69m4bfLfYY/6hZFC1kLCHXKYsR7OirfM6a3YMGh94nQNA=="], | |
| 247 | "@stacksjs/ts-css": ["@stacksjs/ts-css@0.3.16", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "bunfig": "^0.15.17" }, "bin": { "cssx": "./dist/bin/cssx.js", "ts-css": "./dist/bin/cli.js" } }, "sha512-V51ejGqHrK/ACC/JPVgiit0ol8QSERJYR3Hdf7kLV0gRkOHd5GHtPvXKpzCGJKFLwpW/hEEKGnkDYC5ZypkUkg=="], | |
| 248 | 248 | |
| 249 | 249 | "@stacksjs/ts-faker": ["@stacksjs/ts-faker@0.1.8", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "bin": { "@stacksjs/ts-faker": "dist/bin/cli.js", "faker": "dist/bin/cli.js", "fake": "dist/bin/cli.js", "mock": "dist/bin/cli.js" } }, "sha512-YEOIr9mlMeXodcl8FmX3Rq7fqZDcubi7nwdl7lCKr682GIimOQjdN8miQmVEgbBYlpwP1cOn8eO6DhVotMwWnw=="], |
| 250 | 250 | |
| @@ -396,7 +396,7 @@ | ||
| 396 | 396 | |
| 397 | 397 | "bun-plugin-dtsx": ["bun-plugin-dtsx@0.11.5", "", { "dependencies": { "@stacksjs/dtsx": "0.11.5" } }, "sha512-dva7CrHthEYAmnEQu8m0yvaP0joNS3zQQC4TSt7CS/dlEfUYVYU5jTqAB+Iseew4H2HceD5cy57XzpdpG7g9IA=="], |
| 398 | 398 | |
| 399 | "bun-plugin-stx": ["bun-plugin-stx@0.2.401", "", { "dependencies": { "@stacksjs/stx": "0.2.401", "stx-router": "0.2.401" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-O6rEjZwlgG13eZ7gN1IWZPOO9HWz0SeLKOgXgElxl5bcVMfVkXCCJZ7TfjdnlCnvR4iWZ45ZgmZ5Uouz71lHLQ=="], | |
| 399 | "bun-plugin-stx": ["bun-plugin-stx@0.2.532", "", { "dependencies": { "@stacksjs/stx": "0.2.532", "stx-router": "0.2.532" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-0IwYNzbCUQPtpq7sKIxl3lyesPiPKBZw5jgtHEac0LrjL1Ay28JFCgkneXoQFb+mLR7fJC0euFDryPUZ5x0v7w=="], | |
| 400 | 400 | |
| 401 | 401 | "bun-query-builder": ["bun-query-builder@0.2.57", "", { "dependencies": { "@stacksjs/clapp": "^0.2.12", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.2", "dynamodb-tooling": "^0.3.5" }, "bin": { "query-builder": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "qb": "./dist/bin/cli.js" } }, "sha512-wqmZzUnWPLunYJwtv9BCh02/NrDBQ+pDjRppfwbUBNY17VWAOzOtDixST89XuaVTdh6MmbCYIeBzXrCNRjkXoQ=="], |
| 402 | 402 | |
| @@ -444,7 +444,7 @@ | ||
| 444 | 444 | |
| 445 | 445 | "stripe": ["stripe@23.0.0", "", { "peerDependencies": { "@types/node": ">=20" }, "optionalPeers": ["@types/node"] }, "sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA=="], |
| 446 | 446 | |
| 447 | "stx-router": ["stx-router@0.2.401", "", {}, "sha512-WtdMe3rMnJUskdNsbRp5QAVdN8Gucuu7H5GaSReFyk2FMEzYw2HK0YlxruWpckt6y9ipenn+djgsaI9y69GOwQ=="], | |
| 447 | "stx-router": ["stx-router@0.2.532", "", {}, "sha512-2aKVuabYvR1FXNb6huS2/CK02xyugE+xvbsULvb7EyH3LUgL7d7kBKIxpdiT4d+0H9EZ0uCpLLxnb5CPfwsRAQ=="], | |
| 448 | 448 | |
| 449 | 449 | "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], |
| 450 | 450 | |
| @@ -514,6 +514,8 @@ | ||
| 514 | 514 | |
| 515 | 515 | "@stacksjs/cli/@stacksjs/clapp": ["@stacksjs/clapp@0.2.16", "", {}, "sha512-g/5HUXGxD9QffyPKGdywZODte0DJYwzNhSmrPime1z7TLxWPodJ9IYfIwr2G6Wco+fAQISgaVQUtVf0pUNGV0w=="], |
| 516 | 516 | |
| 517 | "@stacksjs/components/ts-video-player": ["ts-video-player@0.1.10", "", { "peerDependencies": { "stx": ">=0.65.0" }, "optionalPeers": ["stx"] }, "sha512-fkKdZIu0CCssKmp/TYMv/hTyhsVzXMo50H6CkfA+b8LHV8yl4JF/bNaM68agqaHXbhBfda1i6wnIUNK+K9aQ5w=="], | |
| 518 | ||
| 517 | 519 | "@stacksjs/config/ts-pantry": ["ts-pantry@0.11.71", "", { "bin": { "ts-pantry": "./dist/bin/cli.js" } }, "sha512-0j1aDIztr9Bw2KVN03TPFu2jgwIlQnKNQtjMmrzT3uhWiQYIwKzchIuQMSfx2zHzMu05iyFCxp7MG3x6vMPw6w=="], |
| 518 | 520 | |
| 519 | 521 | "@stacksjs/database/@stacksjs/ts-validation": ["@stacksjs/ts-validation@0.5.6", "", {}, "sha512-JUWD4ZfbhgIqXJMAaG4YAKUKIudr61Pa1yr0LwjxX5aJfBeN5vexN+iNacjZ1Vp+WTusIuebQnWroU0OKhA9ow=="], |
| @@ -540,9 +542,9 @@ | ||
| 540 | 542 | |
| 541 | 543 | "@stacksjs/stx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.16", "", {}, "sha512-g/5HUXGxD9QffyPKGdywZODte0DJYwzNhSmrPime1z7TLxWPodJ9IYfIwr2G6Wco+fAQISgaVQUtVf0pUNGV0w=="], |
| 542 | 544 | |
| 543 | "@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.401", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-y2+7KUJuFpYlb7hE8J+mIMLAKPDwZxa+o1UbNkqnFOMNnfnt2pIoaE/JZQspoWZi4NKrj1vlrY5ZIaJBIENw/g=="], | |
| 545 | "@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.532", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-yRoPv4p2USgyND9Z7w4/rG+BG8S364zqlY+0Ce5llQQbfP3szJ9dBzOhK8GcP/Nrq6mIumKTROZdkSBOINn48Q=="], | |
| 544 | 546 | |
| 545 | "@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.401", "", {}, "sha512-06yOJojUBDvEQFw/ktci2+E2S3ZE+vCYZuxOKSxr/7XHHHiFmvRuFBmDufvmJvaGUWfUCuW6y6XchXERNQaFbg=="], | |
| 547 | "@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.532", "", {}, "sha512-gICqGCAF15re27ipTouaeqzQOYBQsc/u0HaNKBBS5uSKlY0H7IsfUyJUM8ocScIwOMePX+ZfU/avnGxRETfB2Q=="], | |
| 546 | 548 | |
| 547 | 549 | "@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="], |
| 548 | 550 | |
| @@ -558,7 +560,7 @@ | ||
| 558 | 560 | |
| 559 | 561 | "@stacksjs/ts-css/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="], |
| 560 | 562 | |
| 561 | "@stacksjs/ts-svg/@stacksjs/ts-css": ["@stacksjs/ts-css@0.1.1", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "ts-css": "./dist/bin/cli.js" } }, "sha512-E2lxtiY4LqX+wEr3CeBRw7veZ7M7rlt5rjUErOCV9WCQ3d1SnSI4WADpe8rXkWwxykx3/9prLOF0PgfHfDzlEg=="], | |
| 563 | "@stacksjs/ts-svg/@stacksjs/ts-css": ["@stacksjs/ts-css@0.1.4", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "ts-css": "./dist/bin/cli.js" } }, "sha512-QvHvgagJ6kQY3+zY37andUEJFKq/v4v2JpDCrvVdbX3TYCLuRQExZl8Qf3ai+Fl9BN2oWikH+DfKSs5HuEZrGQ=="], | |
| 562 | 564 | |
| 563 | 565 | "@stacksjs/types/@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.16", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.375", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-aHXXkoYvbTirOFqGqUcQOmlv07mAds4bR/oDKbLB1GqvR8tmjdopHugyir6IWzz9JX+3PpGzCG41HNM7l1UZFw=="], |
| 564 | 566 | |
| @@ -628,6 +630,8 @@ | ||
| 628 | 630 | |
| 629 | 631 | "@stacksjs/ts-css/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.33", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-+5GLKnAI/M/z64UfNLLfQqx/kp+frmRY+NZXYwwGwbrkud/xlsEGDWEExdhqOhqEDMWdb1oafw/ubGAPg1PaFw=="], |
| 630 | 632 | |
| 633 | "@stacksjs/ts-svg/@stacksjs/ts-css/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="], | |
| 634 | ||
| 631 | 635 | "@stacksjs/types/@stacksjs/bunpress/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="], |
| 632 | 636 | |
| 633 | 637 | "@stacksjs/types/bun-query-builder/dynamodb-tooling": ["dynamodb-tooling@0.3.6", "", { "bin": { "dbtooling": "./dist/cli.js" } }, "sha512-G/yixeLt3lawdwXVQCz+IKIPEb+yT21YjDdG1UGZA8YQwS8BwdUoGxBwJJytZfn0LouZ8Sq/YbdNiV2R/VsYnQ=="], |
| @@ -664,6 +668,8 @@ | ||
| 664 | 668 | |
| 665 | 669 | "@stacksjs/ts-css/bunfig/@stacksjs/clarity/bunfig": ["bunfig@0.15.17", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-nJAYGUq29lMmKindrIQIU5MD0JFFRoVFt+PBoNEvvVO9gKS3WgsYQVQLzN0gzHNnZtOZSR9p7jTBiRCwjQpr1g=="], |
| 666 | 670 | |
| 671 | "@stacksjs/ts-svg/@stacksjs/ts-css/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.33", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-+5GLKnAI/M/z64UfNLLfQqx/kp+frmRY+NZXYwwGwbrkud/xlsEGDWEExdhqOhqEDMWdb1oafw/ubGAPg1PaFw=="], | |
| 672 | ||
| 667 | 673 | "@stacksjs/types/@stacksjs/bunpress/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.33", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-+5GLKnAI/M/z64UfNLLfQqx/kp+frmRY+NZXYwwGwbrkud/xlsEGDWEExdhqOhqEDMWdb1oafw/ubGAPg1PaFw=="], |
| 668 | 674 | |
| 669 | 675 | "ts-videos/bunfig/@stacksjs/clarity/bunfig": ["bunfig@0.15.17", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-nJAYGUq29lMmKindrIQIU5MD0JFFRoVFt+PBoNEvvVO9gKS3WgsYQVQLzN0gzHNnZtOZSR9p7jTBiRCwjQpr1g=="], |
| @@ -688,6 +694,8 @@ | ||
| 688 | 694 | |
| 689 | 695 | "@stacksjs/ts-css/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.31", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-lMw327fKgABG7+cPwtJoiEFlrC9foxrMYKRWIagqEtNHKzgxBR17hOAXH27D4hgLDLvkQFUCNDY4kcWXCt62fQ=="], |
| 690 | 696 | |
| 697 | "@stacksjs/ts-svg/@stacksjs/ts-css/bunfig/@stacksjs/clarity/bunfig": ["bunfig@0.15.17", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-nJAYGUq29lMmKindrIQIU5MD0JFFRoVFt+PBoNEvvVO9gKS3WgsYQVQLzN0gzHNnZtOZSR9p7jTBiRCwjQpr1g=="], | |
| 698 | ||
| 691 | 699 | "@stacksjs/types/@stacksjs/bunpress/bunfig/@stacksjs/clarity/bunfig": ["bunfig@0.15.17", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-nJAYGUq29lMmKindrIQIU5MD0JFFRoVFt+PBoNEvvVO9gKS3WgsYQVQLzN0gzHNnZtOZSR9p7jTBiRCwjQpr1g=="], |
| 692 | 700 | |
| 693 | 701 | "ts-videos/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.31", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-lMw327fKgABG7+cPwtJoiEFlrC9foxrMYKRWIagqEtNHKzgxBR17hOAXH27D4hgLDLvkQFUCNDY4kcWXCt62fQ=="], |
| @@ -704,6 +712,8 @@ | ||
| 704 | 712 | |
| 705 | 713 | "@stacksjs/docs/@stacksjs/bunpress/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.31", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-lMw327fKgABG7+cPwtJoiEFlrC9foxrMYKRWIagqEtNHKzgxBR17hOAXH27D4hgLDLvkQFUCNDY4kcWXCt62fQ=="], |
| 706 | 714 | |
| 715 | "@stacksjs/ts-svg/@stacksjs/ts-css/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.31", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-lMw327fKgABG7+cPwtJoiEFlrC9foxrMYKRWIagqEtNHKzgxBR17hOAXH27D4hgLDLvkQFUCNDY4kcWXCt62fQ=="], | |
| 716 | ||
| 707 | 717 | "@stacksjs/types/@stacksjs/bunpress/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clapp": ["@stacksjs/clapp@0.2.12", "", {}, "sha512-EzCjl9cfKNMe7UPONHSSAffyfFVrbLzcEVx2i6HYLnJkcka5HFf1vPvOMkErCC10WDX+2Z1BOMTdsTXP1MqIbQ=="], |
| 708 | 718 | |
| 709 | 719 | "@stacksjs/types/@stacksjs/bunpress/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clarity": ["@stacksjs/clarity@0.3.31", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-lMw327fKgABG7+cPwtJoiEFlrC9foxrMYKRWIagqEtNHKzgxBR17hOAXH27D4hgLDLvkQFUCNDY4kcWXCt62fQ=="], |
config/crosswind.tsmodified+1456-12
| @@ -26,13 +26,32 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind' | ||
| 26 | 26 | * HTML, not over globs, so a content list means nothing here. |
| 27 | 27 | * output OVERRIDDEN to "". The CSS is injected as a <style> tag. |
| 28 | 28 | * minify OVERRIDDEN to false. |
| 29 | * preflights spread into the config and then NEVER EMITTED — toCSS() | |
| 30 | * writes the built-in reset only. Verified by putting a probe | |
| 31 | * token in a preflights entry and grepping the served page for | |
| 32 | * it: zero occurrences, on both a marketing and an app route. | |
| 33 | * This one matters: the obvious home for shared design tokens | |
| 34 | * does not work, so they have to reach the document another | |
| 35 | * way. See the token duplication noted in the styling work. | |
| 29 | * preflights merged and EMITTED. This entry used to say the opposite, | |
| 30 | * and it was true when it was written: the old dev-server | |
| 31 | * merge read only `theme.extend`, `safelist` and `shortcuts` | |
| 32 | * and dropped `preflights`, `darkMode`, `cssVariables`, | |
| 33 | * `rules`, `blocklist` and `variants` outright. stacksjs/stx | |
| 34 | * #1867 unified the two render paths onto one merge, and the | |
| 35 | * installed 0.2.520 carries the fix — its own ts-css-config | |
| 36 | * documents the old behaviour in the past tense. | |
| 37 | * | |
| 38 | * Re-verified here rather than taken from a changelog: | |
| 39 | * mergeCssConfig() reports includePreflight true, carries the | |
| 40 | * entries into `config`, and every rule below comes back out | |
| 41 | * of toCSS(). The stale note mattered, because it is why this | |
| 42 | * app's tokens went to public/app-chrome.css instead. | |
| 43 | * | |
| 44 | * One constraint is real, and it is neither quoting nor | |
| 45 | * structure. The engine replaces a `font-family` written | |
| 46 | * literally in a preflight when the rule is in the SPACED | |
| 47 | * form (`sel { prop: value; }`) and the stack begins | |
| 48 | * `ui-sans-serif`; it substitutes the theme's own sans. | |
| 49 | * Quoting makes no difference and nothing after it is | |
| 50 | * swallowed -- this entry claimed both, from a reading rather | |
| 51 | * than a measurement. Behind a custom property, or read | |
| 52 | * through `var()`, nothing is rewritten, which is what both | |
| 53 | * font-family declarations below do. Several rules in one | |
| 54 | * getCSS() are fine. | |
| 36 | 55 | * |
| 37 | 56 | * Anything not on the surviving list is decoration. This file used to carry |
| 38 | 57 | * `content`, `minify`, and `preflight: true` — and that last one is not even a |
| @@ -43,7 +62,1292 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind' | ||
| 43 | 62 | * |
| 44 | 63 | * @see https://github.com/cwcss/crosswind |
| 45 | 64 | */ |
| 65 | /* | |
| 66 | * The design tokens. Declared here and nowhere else (stx-standards rule 3, | |
| 67 | * §11.2). | |
| 68 | * | |
| 69 | * They were in public/tokens.css, linked into every route from config/ui.ts, | |
| 70 | * on the claim restated at the top of that file: that `preflights` "is spread | |
| 71 | * into the generator config and then never emitted", proven "with a probe | |
| 72 | * token that appears nowhere in the served CSS on any route". That probe was | |
| 73 | * real, and so was the result. It is also what the `preflights` entry above | |
| 74 | * already records as fixed: stacksjs/stx #1867 unified the two render paths | |
| 75 | * onto one merge, and this config has carried the correction for a while. Only | |
| 76 | * the two stylesheets still said it, which is why 83 declarations lived | |
| 77 | * outside this file. | |
| 78 | * | |
| 79 | * Re-probed on the installed stx 0.2.532 before moving anything, the same way | |
| 80 | * the original probe was run: mergeCssConfig() reports includePreflight true, | |
| 81 | * carries the entries into `config`, and a probe token placed in `preflights` | |
| 82 | * comes back out of toCSS(). DATE_RANGE_PICKER_CSS below had been reaching | |
| 83 | * /analytics the whole time, which is the same proof from the other direction. | |
| 84 | * | |
| 85 | * Still FIRST, which is what the old link order said: before APP_CHROME so | |
| 86 | * that can read the palette, and before /marketing.css, which the marketing | |
| 87 | * layout adds and which declares a richer overlapping set. Later still wins, | |
| 88 | * and marketing.css is unlayered while this is @layer tc-base, so it wins | |
| 89 | * harder than it did. That is the direction it already wanted. | |
| 90 | */ | |
| 91 | const TOKENS = ` | |
| 92 | :root { | |
| 93 | /* The one declaration that reaches the parts of the page we do not draw. | |
| 94 | * | |
| 95 | * Native UI — the select popup, scrollbars, the date and colour pickers, form | |
| 96 | * control backgrounds, the spellcheck underline, the caret — is painted by the | |
| 97 | * browser from \`color-scheme\`, not from our custom properties. Undeclared it | |
| 98 | * defaults to \`light\`, so on a dark loghq the project dropdown opened as a | |
| 99 | * white popup on a #090b11 page and the scrollbars stayed light. No amount of | |
| 100 | * token work reaches those pixels; this is the only thing that does. | |
| 101 | * | |
| 102 | * It is declared on all four blocks below and not once on \`:root\` with both | |
| 103 | * values, because \`color-scheme: light dark\` means "either is fine, follow the | |
| 104 | * OS" — which is wrong the moment someone picks a theme explicitly. The | |
| 105 | * attribute blocks have to be able to override the media query, exactly like | |
| 106 | * every colour here. */ | |
| 107 | color-scheme: light; | |
| 108 | --bg: #fbfbfd; | |
| 109 | --panel: #ffffff; | |
| 110 | --border: rgba(15, 23, 42, 0.09); | |
| 111 | --text: #0b0f19; | |
| 112 | --text-2: #4b5565; | |
| 113 | /* --text-3 was #97a1b2, which measured 2.52:1 against --bg and 2.61:1 | |
| 114 | * against --panel. WCAG AA wants 4.5:1 for body text, so every "subtle" | |
| 115 | * label in the app failed it — 73 elements in light and 82 in dark, measured | |
| 116 | * in a real browser across seven pages. It is the same token in both themes | |
| 117 | * and it failed in both (dark was #667085, 3.73:1 on --panel). | |
| 118 | * | |
| 119 | * #6b7280 gives 4.83:1 on --panel and 4.68:1 on --bg; the dark #79839a gives | |
| 120 | * 4.88:1 and 5.18:1. Both are the smallest step that clears AA on the worse | |
| 121 | * of the two surfaces, so "subtle" still reads as subtle. */ | |
| 122 | --text-3: #6b7280; | |
| 123 | /* Emerald, not the rose this shipped with. | |
| 124 | * | |
| 125 | * The old --accent was #e11d48, which is byte-for-byte --sev-error below. In | |
| 126 | * a product whose whole surface is log severity, the brand colour WAS the | |
| 127 | * error colour: every button, link and focus ring carried the one hue the | |
| 128 | * severity scale reserves for something being wrong. | |
| 129 | * | |
| 130 | * Green collides with --ok instead, which is the milder trade. --ok is a UI | |
| 131 | * success state, not a severity: nothing in the sev-* scale is green, so the | |
| 132 | * severity reading stays unambiguous. Brand-as-healthy misleads nobody; | |
| 133 | * brand-as-error did. | |
| 134 | * | |
| 135 | * #047857 rather than the more obvious #059669: emerald-600 measures 3.65:1 | |
| 136 | * on --bg, under the 4.5 floor this file holds everything else to. This one | |
| 137 | * measures 5.31:1 on --bg and 5.48:1 on --panel, which is also an improvement | |
| 138 | * on the rose it replaces (4.54:1, passing by 0.04). */ | |
| 139 | --accent: #047857; | |
| 140 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 141 | /* Ink for text sitting ON a saturated fill — the accent, --warn, and the | |
| 142 | * Slack/Discord brand colours. | |
| 143 | * | |
| 144 | * It cannot be a hardcoded #fff, which is what the app markup and | |
| 145 | * app-chrome.css assumed. In dark mode the fills get LIGHTER, not darker: | |
| 146 | * --accent is #34d399 and the dashboard's indigo is #818cf8, where white | |
| 147 | * measures 1.92:1 and 2.98:1. The dark value here measures 9.83:1 and 6.34:1 | |
| 148 | * on those same fills, and 6.83:1 on the dark --warn. | |
| 149 | * | |
| 150 | * marketing.css has had this token, correctly themed, since the marketing | |
| 151 | * pages were built; it just never reached the app surface, because that | |
| 152 | * sheet only loads on marketing routes. Declared here it covers both, and | |
| 153 | * marketing.css no longer declares its own copy — see the .reveal note there | |
| 154 | * for what two copies of one token cost. */ | |
| 155 | --accent-ink: #ffffff; | |
| 156 | --ok: #16a34a; | |
| 157 | --warn: #b91c1c; | |
| 158 | --sans: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; | |
| 159 | --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 160 | ||
| 161 | /* Log severity. These were six hex literals inside a levelColor() switch in | |
| 162 | * dashboard.stx, with two of them repeated in the stats array — so the same | |
| 163 | * "warning amber" was written twice and could drift. | |
| 164 | * | |
| 165 | * They were also a single set used on BOTH themes. The dark values below are | |
| 166 | * exactly what shipped before, so dark is unchanged; light gets darker, | |
| 167 | * higher-contrast variants, because #fb7185 on a #fbfbfd background is not a | |
| 168 | * readable label. */ | |
| 169 | --sev-critical: #be123c; | |
| 170 | --sev-error: #e11d48; | |
| 171 | --sev-warning: #b45309; | |
| 172 | --sev-notice: #0369a1; | |
| 173 | --sev-info: #1d4ed8; | |
| 174 | --sev-debug: #64748b; | |
| 175 | } | |
| 176 | ||
| 177 | @media (prefers-color-scheme: dark) { | |
| 178 | :root { | |
| 179 | color-scheme: dark; | |
| 180 | --bg: #090b11; | |
| 181 | --panel: #10131c; | |
| 182 | --border: rgba(148, 163, 184, 0.12); | |
| 183 | --text: #f3f5f9; | |
| 184 | --text-2: #b3bccb; | |
| 185 | --text-3: #79839a; | |
| 186 | --accent: #34d399; | |
| 187 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 188 | --accent-ink: #04140d; | |
| 189 | --ok: #4ade80; | |
| 190 | --warn: #f87171; | |
| 191 | --sev-critical: #f43f5e; | |
| 192 | --sev-error: #fb7185; | |
| 193 | --sev-warning: #f59e0b; | |
| 194 | --sev-notice: #38bdf8; | |
| 195 | --sev-info: #60a5fa; | |
| 196 | --sev-debug: #94a3b8; | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | :root[data-theme='dark'] { | |
| 201 | color-scheme: dark; | |
| 202 | --bg: #090b11; | |
| 203 | --panel: #10131c; | |
| 204 | --border: rgba(148, 163, 184, 0.12); | |
| 205 | --text: #f3f5f9; | |
| 206 | --text-2: #b3bccb; | |
| 207 | --text-3: #79839a; | |
| 208 | --accent: #34d399; | |
| 209 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 210 | --accent-ink: #04140d; | |
| 211 | --ok: #4ade80; | |
| 212 | --warn: #f87171; | |
| 213 | --sev-critical: #f43f5e; | |
| 214 | --sev-error: #fb7185; | |
| 215 | --sev-warning: #f59e0b; | |
| 216 | --sev-notice: #38bdf8; | |
| 217 | --sev-info: #60a5fa; | |
| 218 | --sev-debug: #94a3b8; | |
| 219 | } | |
| 220 | ||
| 221 | :root[data-theme='light'] { | |
| 222 | color-scheme: light; | |
| 223 | --bg: #fbfbfd; | |
| 224 | --panel: #ffffff; | |
| 225 | --border: rgba(15, 23, 42, 0.09); | |
| 226 | --text: #0b0f19; | |
| 227 | --text-2: #4b5565; | |
| 228 | --text-3: #6b7280; | |
| 229 | --accent: #047857; | |
| 230 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 231 | --accent-ink: #ffffff; | |
| 232 | --ok: #16a34a; | |
| 233 | --warn: #b91c1c; | |
| 234 | --sev-critical: #be123c; | |
| 235 | --sev-error: #e11d48; | |
| 236 | --sev-warning: #b45309; | |
| 237 | --sev-notice: #0369a1; | |
| 238 | --sev-info: #1d4ed8; | |
| 239 | --sev-debug: #64748b; | |
| 240 | } | |
| 241 | ||
| 242 | body { | |
| 243 | background: var(--bg); | |
| 244 | color: var(--text); | |
| 245 | font-family: var(--sans); | |
| 246 | -webkit-font-smoothing: antialiased; | |
| 247 | } | |
| 248 | ||
| 249 | /* Skeleton shimmer, from account.stx's <style> block. Utilities cannot declare | |
| 250 | * a @keyframes, so this is a preflight, which is the documented home for one. | |
| 251 | * | |
| 252 | * Named loghq-pulse rather than pulse on purpose. Crosswind emits its own | |
| 253 | * \`@keyframes pulse\` (0%,100% opacity 1; 50% opacity .5) as soon as any page | |
| 254 | * uses \`animate-pulse\`, into a sheet that is linked AFTER this file. A | |
| 255 | * same-named keyframe there would silently replace this one and change the | |
| 256 | * animation rather than error. | |
| 257 | */ | |
| 258 | @keyframes loghq-pulse { | |
| 259 | 0%, | |
| 260 | 100% { | |
| 261 | opacity: 0.5; | |
| 262 | } | |
| 263 | 50% { | |
| 264 | opacity: 0.9; | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | /* The one element that uses it, as a named class rather than an arbitrary | |
| 269 | * \`[animation:loghq-pulse_1.4s_ease-in-out_infinite]\` utility. | |
| 270 | * | |
| 271 | * That utility worked. What never worked was the guard beside it, | |
| 272 | * \`motion-reduce:[animation:none]\`: a variant on an arbitrary property is not | |
| 273 | * recognised by crosswind's class extractor, so it produced no rule at all and | |
| 274 | * the skeleton kept pulsing for people who asked their system not to animate. | |
| 275 | * Silent, as that whole class of miss is -- no error, no warning, nothing in | |
| 276 | * the sheet. Filed as stacksjs/ts-css#37; the generator side of it is fixed in | |
| 277 | * 0.3.14 (#36), the extractor is not. | |
| 278 | * | |
| 279 | * Both halves live here, in source order, so the reduced-motion case does not | |
| 280 | * depend on the extractor, on crosswind's output ordering, or on an | |
| 281 | * !important. It also cannot be separated from the keyframes it guards. Note | |
| 282 | * this file is linked BEFORE the generated sheet, which is why the animation | |
| 283 | * is declared here rather than left as a utility for this rule to fight. | |
| 284 | */ | |
| 285 | .skeleton-pulse { | |
| 286 | animation: loghq-pulse 1.4s ease-in-out infinite; | |
| 287 | } | |
| 288 | ||
| 289 | @media (prefers-reduced-motion: reduce) { | |
| 290 | .skeleton-pulse { | |
| 291 | animation: none; | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | /* Theme-toggle icon swap. Three identical copies of this lived in | |
| 296 | * dashboard.stx, settings.stx and projects/index.stx. | |
| 297 | * | |
| 298 | * It stays CSS because no utility can express it: the selector is an | |
| 299 | * attribute-state descendant, \`[data-t="light"] .ic-sun\`, driven by the | |
| 300 | * \`theme()\` signal through \`:data-t\` on the button. The alternative — a | |
| 301 | * \`:show\` binding on each icon — would need \`style="display:none"\` on both to | |
| 302 | * avoid showing both icons before hydration, trading a state rule for two | |
| 303 | * inline styles and a hydration dependency on something that is currently | |
| 304 | * pure CSS. | |
| 305 | */ | |
| 306 | .theme-btn .ic-sun, | |
| 307 | .theme-btn .ic-moon { | |
| 308 | display: none; | |
| 309 | } | |
| 310 | ||
| 311 | /* Keyed off the ROOT element, not off the button. | |
| 312 | * | |
| 313 | * These used to read \`.theme-btn[data-t='light']\`, where data-t came from a | |
| 314 | * \`:data-t="theme()"\` binding — a client signal. So before hydration the button | |
| 315 | * carried no data-t at all, neither rule matched, and the pair above left BOTH | |
| 316 | * icons display:none: the theme control rendered as an empty box on every cold | |
| 317 | * load. There is no server-known theme to put in a static counterpart either, | |
| 318 | * so the fix is not a static attribute; it is keying off something that IS set | |
| 319 | * before first paint. | |
| 320 | * | |
| 321 | * stx's color-mode boot script sets data-theme on the root above every | |
| 322 | * stylesheet (config/ui.ts app.colorMode), and useColorMode keeps it current | |
| 323 | * afterwards, so the correct icon paints immediately and the mirror signal the | |
| 324 | * binding needed is gone from all three pages. */ | |
| 325 | :root[data-theme='light'] .theme-btn .ic-sun { | |
| 326 | display: block; | |
| 327 | } | |
| 328 | ||
| 329 | :root[data-theme='dark'] .theme-btn .ic-moon { | |
| 330 | display: block; | |
| 331 | } | |
| 332 | ` | |
| 333 | ||
| 334 | /* | |
| 335 | * App-surface CSS that utilities provably cannot express: pseudo-elements, | |
| 336 | * attribute-state selectors, bare element selectors, @keyframes, and the | |
| 337 | * dashboard's page-scoped accent. | |
| 338 | * | |
| 339 | * From public/app-chrome.css, which opened by naming this file as where | |
| 340 | * ch.11.2/11.7 says it belongs and then explaining why it could not be here. | |
| 341 | * See TOKENS above for why that reason no longer holds. | |
| 342 | * | |
| 343 | * Rules that WERE expressible are deliberately absent -- they live as | |
| 344 | * utilities in the markup. Do not move a rule back here because it is more | |
| 345 | * convenient. | |
| 346 | * | |
| 347 | * The cascade, measured rather than reasoned about, because this sheet used to | |
| 348 | * be linked between /tokens.css and /marketing.css and is now layered: | |
| 349 | * | |
| 350 | * against a utility A utility is unlayered and this is @layer tc-base, so a | |
| 351 | * utility wins outright instead of winning ties on order. That is what the | |
| 352 | * old header asked for ("before the Crosswind sheet so utilities still win | |
| 353 | * a specificity tie"), now unconditional. Nothing here relied on beating | |
| 354 | * one by specificity: every selector was checked against the app markup. | |
| 355 | * | |
| 356 | * against marketing.css Zero class names are shared between the two files. | |
| 357 | * The only selectors both write are `:root`, `html` and two at-rules, and | |
| 358 | * they do not collide: this declares `--slack`/`--discord` where marketing | |
| 359 | * declares `--bg`/`--text`/`--surface`, both set `scroll-behavior: smooth` | |
| 360 | * on `html` to the same value, and the three reduced-motion blocks across | |
| 361 | * the two files target disjoint classes with no !important between them. | |
| 362 | * So the layer change reverses nothing. | |
| 363 | * | |
| 364 | * Verified end to end by diffing the computed value of 58 properties on every | |
| 365 | * element of 11 served pages, before against after. | |
| 366 | */ | |
| 367 | const APP_CHROME = ` | |
| 368 | /* --------------------------------------------------------------------------- | |
| 369 | * Page-scoped tokens | |
| 370 | * | |
| 371 | * dashboard is the one surface with its own accent: indigo, so navigation | |
| 372 | * never reads as a severity colour (see 20f40ed). It used to be a \`:root\` | |
| 373 | * block inside dashboard.stx's <style>, scoped only by the accident of which | |
| 374 | * page carried the tag. Declared globally that would repaint the whole app, so | |
| 375 | * it hangs off a wrapper class on the dashboard's root element and cascades to | |
| 376 | * its descendants — including the fixed-position chrome, which is inside the | |
| 377 | * wrapper. | |
| 378 | * ------------------------------------------------------------------------ */ | |
| 379 | .page-dashboard { | |
| 380 | --accent: #4f46e5; | |
| 381 | --rail: rgba(79, 70, 229, 0.14); | |
| 382 | } | |
| 383 | ||
| 384 | @media (prefers-color-scheme: dark) { | |
| 385 | .page-dashboard { | |
| 386 | --accent: #818cf8; | |
| 387 | --rail: rgba(129, 140, 248, 0.16); | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | :root[data-theme='dark'] .page-dashboard { | |
| 392 | --accent: #818cf8; | |
| 393 | --rail: rgba(129, 140, 248, 0.16); | |
| 394 | } | |
| 395 | ||
| 396 | :root[data-theme='light'] .page-dashboard { | |
| 397 | --accent: #4f46e5; | |
| 398 | --rail: rgba(79, 70, 229, 0.14); | |
| 399 | } | |
| 400 | ||
| 401 | /* Channel brand colours. Only settings uses them, but they are declared here | |
| 402 | * with everything else rather than in a page tag. */ | |
| 403 | :root { | |
| 404 | --slack: #611f69; | |
| 405 | --discord: #5865f2; | |
| 406 | } | |
| 407 | ||
| 408 | @media (prefers-color-scheme: dark) { | |
| 409 | :root { | |
| 410 | --discord: #818cf8; | |
| 411 | } | |
| 412 | } | |
| 413 | ||
| 414 | :root[data-theme='dark'] { | |
| 415 | --discord: #818cf8; | |
| 416 | } | |
| 417 | ||
| 418 | :root[data-theme='light'] { | |
| 419 | --discord: #5865f2; | |
| 420 | } | |
| 421 | ||
| 422 | /* Tabular numerals for the log stream's counts and timestamps, so digits do | |
| 423 | * not jitter as values change. | |
| 424 | * | |
| 425 | * This is a real rule rather than an arbitrary property because the value has | |
| 426 | * to be quoted — \`font-feature-settings: 'tnum' 1\` — and a quoted value cannot | |
| 427 | * survive Crosswind's class-name syntax. Written as | |
| 428 | * [font-feature-settings:'tnum'_1] it generates NOTHING; the three | |
| 429 | * font-feature-settings declarations in the served sheet are all preflight | |
| 430 | * resets, and the computed value on the element stayed \`normal\`. | |
| 431 | * | |
| 432 | * \`tabular-nums\` is not a substitute: it sets font-variant-numeric, a | |
| 433 | * different property, and would not reproduce the measured baseline. | |
| 434 | */ | |
| 435 | .tnum { | |
| 436 | font-feature-settings: 'tnum' 1; | |
| 437 | } | |
| 438 | ||
| 439 | /* --------------------------------------------------------------------------- | |
| 440 | * Bare element selectors — no utility targets an element type | |
| 441 | * ------------------------------------------------------------------------ */ | |
| 442 | html { | |
| 443 | scroll-behavior: smooth; | |
| 444 | } | |
| 445 | ||
| 446 | /* --------------------------------------------------------------------------- | |
| 447 | * settings: the alert-channel toggle | |
| 448 | * | |
| 449 | * The knob is a ::after pseudo-element and both states are attribute-driven. | |
| 450 | * Crosswind generates nothing for \`after:\` or for a \`[data-on]\` variant. | |
| 451 | * ------------------------------------------------------------------------ */ | |
| 452 | .tgl::after { | |
| 453 | content: ''; | |
| 454 | position: absolute; | |
| 455 | top: 2px; | |
| 456 | left: 2px; | |
| 457 | width: 18px; | |
| 458 | height: 18px; | |
| 459 | border-radius: 999px; | |
| 460 | background: #fff; | |
| 461 | transition: transform 0.15s ease; | |
| 462 | } | |
| 463 | ||
| 464 | /* The four rules below carry !important, and they are exactly the ones whose | |
| 465 | * element also carries a utility for the same property. While this was a | |
| 466 | * linked stylesheet they won on specificity -- an attribute-state or | |
| 467 | * tag-scoped selector outranks a single-class utility. As a preflight they are | |
| 468 | * in @layer tc-base and a utility is unlayered, which beats a layer at ANY | |
| 469 | * specificity, so without the marks they stop applying. | |
| 470 | * | |
| 471 | * Measured on a probe page carrying the real class lists from settings.stx and | |
| 472 | * dashboard.stx, with the before and after sheets swapped underneath it: | |
| 473 | * | |
| 474 | * .tgl[data-on='1'] rgb(74,222,128) -> rgba(148,163,184,0.12), ie | |
| 475 | * the toggle stops turning green and reads as off | |
| 476 | * in both states (loses to \`bg-line\`) | |
| 477 | * .seg-slack[data-sel='1'] rgb(97,31,105) -> rgb(16,19,28), the selected | |
| 478 | * channel loses its brand fill (\`bg-panel\`), and | |
| 479 | * .seg's own colour goes with it (\`text-muted\`) | |
| 480 | * .dz-overlay[hidden] display none -> flex, so the delete-zone modal | |
| 481 | * is permanently open over the settings page | |
| 482 | * (loses to \`flex\`) | |
| 483 | * a.pill:hover the hover paint stops arriving at all, because | |
| 484 | * the utilities it has to beat (\`bg-accent\`, | |
| 485 | * \`border-accent\`, \`text-accent-ink\`) carry no | |
| 486 | * hover variant and so apply in every state | |
| 487 | * | |
| 488 | * Marked per declaration rather than per rule: only the properties that | |
| 489 | * actually collide, so the override is exactly as wide as the utility it has | |
| 490 | * to beat. */ | |
| 491 | .tgl[data-on='1'] { | |
| 492 | background: var(--ok) !important; | |
| 493 | } | |
| 494 | ||
| 495 | .tgl[data-on='1']::after { | |
| 496 | transform: translateX(16px); | |
| 497 | } | |
| 498 | ||
| 499 | /* settings: the Slack/Discord channel picker. Selected state is attribute | |
| 500 | * driven, and each brand paints its own fill. */ | |
| 501 | .seg[data-sel='1'] { | |
| 502 | color: var(--accent-ink) !important; | |
| 503 | border-color: transparent !important; | |
| 504 | } | |
| 505 | ||
| 506 | .seg-slack[data-sel='1'] { | |
| 507 | background: var(--slack) !important; | |
| 508 | } | |
| 509 | ||
| 510 | .seg-discord[data-sel='1'] { | |
| 511 | background: var(--discord) !important; | |
| 512 | } | |
| 513 | ||
| 514 | /* settings: delete-zone modal. \`[hidden]\` has to beat the overlay's own | |
| 515 | * \`display: flex\`, which a utility cannot guarantee — the engine's own | |
| 516 | * preflight \`[hidden]{display:none}\` ties with \`.flex\` on specificity and | |
| 517 | * loses on order, and this rule, being layered, loses to it outright. Hence | |
| 518 | * the mark. */ | |
| 519 | .dz-overlay[hidden] { | |
| 520 | display: none !important; | |
| 521 | } | |
| 522 | ||
| 523 | @keyframes dz-fade { | |
| 524 | from { | |
| 525 | opacity: 0; | |
| 526 | } | |
| 527 | ||
| 528 | to { | |
| 529 | opacity: 1; | |
| 530 | } | |
| 531 | } | |
| 532 | ||
| 533 | @keyframes dz-pop { | |
| 534 | from { | |
| 535 | opacity: 0; | |
| 536 | transform: translateY(6px) scale(0.98); | |
| 537 | } | |
| 538 | ||
| 539 | to { | |
| 540 | opacity: 1; | |
| 541 | transform: none; | |
| 542 | } | |
| 543 | } | |
| 544 | ||
| 545 | /* --------------------------------------------------------------------------- | |
| 546 | * dashboard | |
| 547 | * | |
| 548 | * Moved verbatim from dashboard.stx's <style> block. Every family below is | |
| 549 | * entangled with a selector no utility reaches, so splitting them would have | |
| 550 | * meant half a component in each place: | |
| 551 | * | |
| 552 | * .pill* hover/active are tag-scoped (\`a.pill:hover, button.pill:hover\`) | |
| 553 | * on purpose — the disabled pager is a <span class="pill"> and | |
| 554 | * must NOT light up on hover. | |
| 555 | * .log-row \`details[open] > .log-row\` has to outrank the hover state. | |
| 556 | * .search-input ::placeholder and :focus-visible. | |
| 557 | * .chrome-* \`.chrome-pro, .chrome-pro:hover\` cancels .chrome-upgrade's | |
| 558 | * hover: Pro is a status, not an action. | |
| 559 | * summary bare element selector plus ::-webkit-details-marker. | |
| 560 | * :focus-visible a bare-element accessibility rule, not optional. | |
| 561 | * .grain, data-URI backgrounds with prefers-color-scheme variants. | |
| 562 | * select.app-panel | |
| 563 | * .reveal @keyframes plus a reduced-motion variant. | |
| 564 | * | |
| 565 | * What IS expressible went the other way and is now utilities in the markup: | |
| 566 | * .mono, .wordmark, .wordmark-link, .panel, .lvl-chip, .ctx-pre and .icon-btn. | |
| 567 | * ------------------------------------------------------------------------ */ | |
| 568 | .pill { border-radius: 999px; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; } | |
| 569 | a.pill:hover, button.pill:hover { color: var(--text) !important; border-color: color-mix(in srgb, var(--accent) 45%, var(--border)) !important; background: color-mix(in srgb, var(--accent) 6%, transparent) !important; } | |
| 570 | a.pill:active, button.pill:active { transform: translateY(1px); } | |
| 571 | /* Filter-tab state. These were inline style ternaries on every tab, which | |
| 572 | outrank the hover rules above and could not be themed. The two levels differ | |
| 573 | because the severity tabs are the primary control and the range tabs are | |
| 574 | secondary. */ | |
| 575 | .pill-on { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 576 | a.pill-on:hover { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 577 | .pill-off { color: var(--text-2); border-color: var(--border); } | |
| 578 | .pill-on-soft { color: var(--accent); border-color: var(--accent); } | |
| 579 | .pill-off-soft { color: var(--text-3); border-color: var(--border); } | |
| 580 | .log-row { transition: background-color 0.12s ease; } | |
| 581 | .log-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); } | |
| 582 | details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); } | |
| 583 | summary { list-style: none; cursor: pointer; } | |
| 584 | summary::-webkit-details-marker { display: none; } | |
| 585 | .search-input { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; color: var(--text); outline: none; transition: border-color 0.15s ease; } | |
| 586 | .search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } | |
| 587 | .search-input::placeholder { color: var(--text-3); } | |
| 588 | /* Position was an inline style string assembled in JS. It lives here now, so | |
| 589 | the chrome is styleable and themeable like everything else. */ | |
| 590 | .chrome-logout, .chrome-upgrade { position: fixed; top: 16px; z-index: 60; padding: 8px 13px; border-radius: 9px; font-size: 13px; line-height: 1; font-family: var(--sans, ui-sans-serif); } | |
| 591 | .chrome-logout { right: 18px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); transition: color 0.15s ease, border-color 0.15s ease; } | |
| 592 | .chrome-logout:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } | |
| 593 | .chrome-upgrade { right: 112px; font-weight: 600; text-decoration: none; background: var(--accent); color: var(--accent-ink); transition: filter 0.15s ease, transform 0.12s ease; } | |
| 594 | .chrome-upgrade:hover { filter: brightness(1.12); } | |
| 595 | .chrome-upgrade:active { transform: translateY(1px); } | |
| 596 | /* Pro is a status, not an action: no link, no hover lift. */ | |
| 597 | .chrome-pro, .chrome-pro:hover { background: transparent; color: var(--accent); border: 1px solid var(--accent); cursor: default; filter: none; } | |
| 598 | /* The ONLY .reveal in the app. marketing.css carried a second copy with | |
| 599 | different values (0.6s/12px against this one's 0.5s/10px) and a second | |
| 600 | @keyframes of the same name; both sheets load on every marketing page, so the | |
| 601 | marketing copy won there and this one only ever reached the dashboard. These | |
| 602 | are the marketing values, kept because 23 pages used them and one did not. */ | |
| 603 | .reveal { opacity: 0; transform: translateY(12px); animation: reveal 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| 604 | @keyframes reveal { to { opacity: 1; transform: none; } } | |
| 605 | @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } } | |
| 606 | /* Keyboard focus — a visible ring on every interactive control (accessibility, | |
| 607 | not optional). Search gets an inset ring so it doesn't clash with its pill. | |
| 608 | ||
| 609 | This rule used to also declare \`border-radius: 8px\`, which was not a property | |
| 610 | of the ring — it RESHAPED THE ELEMENT for as long as it held focus. Tabbing | |
| 611 | onto a filter tab squared off its 999px pill; the same happened to the pager | |
| 612 | and to every rounded control here, because \`a:focus-visible\` (0,1,1) outranks | |
| 613 | \`.pill\` (0,1,0). Nothing about a keyboard user's focus should change an | |
| 614 | element's geometry. | |
| 615 | ||
| 616 | Nothing replaces it: \`outline\` has followed the element's own border-radius | |
| 617 | since Chrome 94 / Firefox 88 / Safari 16.4, so the ring is correctly rounded | |
| 618 | on a pill and correctly square on a square control, which is what the 8px was | |
| 619 | reaching for in the first place. */ | |
| 620 | a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible { | |
| 621 | outline: 2px solid var(--accent); outline-offset: 2px; | |
| 622 | } | |
| 623 | summary:focus-visible { outline-offset: -2px; } | |
| 624 | .search-input:focus-visible { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } | |
| 625 | /* Physical press feedback on a log row. */ | |
| 626 | .log-row:active { background: color-mix(in srgb, var(--accent) 9%, transparent); } | |
| 627 | /* Log stream aligned as fixed columns: rail · time · level · channel · message. */ | |
| 628 | .log-row { display: grid; grid-template-columns: 3px 4.75rem 3.75rem 6rem minmax(0, 1fr); align-items: center; column-gap: 0.75rem; } | |
| 629 | /* Subtle grain so the flat surfaces don't read as sterile. Fixed, non-blocking, | |
| 630 | barely-there; sits under the floating chrome (z-60). */ | |
| 631 | .grain { position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0.03; | |
| 632 | background-size: 170px 170px; | |
| 633 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } | |
| 634 | @media (prefers-reduced-motion: no-preference) { .grain { opacity: 0.032; } } | |
| 635 | /* Project dropdown: the native select chevron hugged the rounded border. Reset | |
| 636 | the native appearance and draw our own with a comfortable inset + room for the | |
| 637 | value text. Chevron is themed to --text-3 in each mode. */ | |
| 638 | select.app-panel { | |
| 639 | -webkit-appearance: none; -moz-appearance: none; appearance: none; | |
| 640 | /* The one declaration here that a utility also sets: the element carries | |
| 641 | * \`px-3\`, and layered this rule loses to it outright where it used to win | |
| 642 | * on specificity. Measured: padding-right 32px -> 12px, which puts the | |
| 643 | * chevron on top of the project name. The appearance and background | |
| 644 | * properties have no utility on this element and stay unmarked. */ | |
| 645 | padding-right: 2rem !important; background-repeat: no-repeat; | |
| 646 | background-position: right 0.7rem center; background-size: 13px; | |
| 647 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2397a1b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); | |
| 648 | } | |
| 649 | @media (prefers-color-scheme: dark) { select.app-panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); } } | |
| 650 | ||
| 651 | /* Log severity, driven by one data attribute rather than an inline style. | |
| 652 | * | |
| 653 | * The rail and the level badge on a log row both used | |
| 654 | * \`style="background: "\`, and the stat dot and | |
| 655 | * value used \`style="color: "\` — four inline declarations carrying | |
| 656 | * hex literals computed in the view. \`data-level\` on the row sets \`--sev\` once | |
| 657 | * and both children read it, so the colour is named in exactly one place | |
| 658 | * (public/tokens.css) and follows the theme. | |
| 659 | * | |
| 660 | * The default covers a level the server does not recognise: it falls back to | |
| 661 | * the debug tone rather than to no colour, so the rail never disappears. */ | |
| 662 | [data-level] { --sev: var(--sev-debug); } | |
| 663 | [data-level='emergency'], [data-level='alert'], [data-level='critical'] { --sev: var(--sev-critical); } | |
| 664 | [data-level='error'] { --sev: var(--sev-error); } | |
| 665 | [data-level='warning'] { --sev: var(--sev-warning); } | |
| 666 | [data-level='notice'] { --sev: var(--sev-notice); } | |
| 667 | [data-level='info'] { --sev: var(--sev-info); } | |
| 668 | .log-rail { background: var(--sev); min-height: 20px; } | |
| 669 | .log-level { color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); } | |
| 670 | /* One segment of the level-mix bars on /analytics. Reads the same --sev the | |
| 671 | * rail and the chip do, so a colour still means the same severity across the | |
| 672 | * whole app and the analytics page names no palette of its own. */ | |
| 673 | .level-seg { background: var(--sev); } | |
| 674 | ||
| 675 | /* Stat cards. \`data-tone\` is set only when the count is non-zero, so an empty | |
| 676 | * warnings/errors card stays neutral — which is what \`s.on\` expressed inline. */ | |
| 677 | .stat-dot { background: var(--text-3); opacity: 0.35; } | |
| 678 | .stat-value { color: var(--text); } | |
| 679 | [data-tone='warning'] .stat-dot { background: var(--sev-warning); opacity: 1; } | |
| 680 | [data-tone='warning'] .stat-value { color: var(--sev-warning); } | |
| 681 | [data-tone='error'] .stat-dot { background: var(--sev-critical); opacity: 1; } | |
| 682 | [data-tone='error'] .stat-value { color: var(--sev-critical); } | |
| 683 | ||
| 684 | /* --------------------------------------------------------------------------- | |
| 685 | * Narrow viewports | |
| 686 | * | |
| 687 | * This sheet had NO width query at all, while marketing.css has four. The app | |
| 688 | * surface is the half that people actually work in, and it was the half with no | |
| 689 | * mobile behaviour — the two rules below are both fixed geometry that a phone | |
| 690 | * cannot satisfy. | |
| 691 | * | |
| 692 | * Everything here adjusts rules THIS FILE owns. It deliberately does not try to | |
| 693 | * override a utility (the page's own \`px-5 py-10\`, for instance): this sheet is | |
| 694 | * linked before the Crosswind sheet precisely so utilities win a specificity | |
| 695 | * tie, so a \`.page-dashboard { padding-top }\` here would be silently dead. Any | |
| 696 | * responsive change to a utility-styled element belongs in the markup as an | |
| 697 | * \`sm:\`/\`md:\` variant. | |
| 698 | * | |
| 699 | * 40rem rather than a round 375px: the breakpoint is where the content stops | |
| 700 | * fitting, and it is in rem so it also fires for someone at a large default | |
| 701 | * font size on a wider screen, who has the same problem for a different reason. | |
| 702 | * ------------------------------------------------------------------------ */ | |
| 703 | @media (max-width: 40rem) { | |
| 704 | /* The log stream was a single row of five fixed columns: | |
| 705 | * 3px rail + 4.75rem time + 3.75rem level + 6rem channel | |
| 706 | * plus four 0.75rem gaps and the row's own 20px side padding — 323px spoken | |
| 707 | * for before the message column starts. On a 375px phone that leaves the | |
| 708 | * message 52px, about four characters, on the one line that carries what | |
| 709 | * actually happened. The row was not narrow, it was empty. | |
| 710 | * | |
| 711 | * It becomes two rows: metadata above, message across the full width below. | |
| 712 | * Nothing is dropped — the channel is not repeated in the expanded panel, so | |
| 713 | * hiding it would have lost the only place it appears. | |
| 714 | * | |
| 715 | * Addressed by position because these are bare spans in the markup with no | |
| 716 | * hooks of their own, and the order is fixed by the template: rail, time, | |
| 717 | * level, channel, message. */ | |
| 718 | .log-row { | |
| 719 | grid-template-columns: 3px auto auto minmax(0, 1fr); | |
| 720 | column-gap: 0.5rem; | |
| 721 | row-gap: 0.2rem; | |
| 722 | } | |
| 723 | ||
| 724 | /* The rail spans both rows so it stays one continuous severity stripe beside | |
| 725 | * the whole entry rather than a stub next to the timestamp. */ | |
| 726 | .log-row > :nth-child(1) { grid-row: 1 / span 2; } | |
| 727 | .log-row > :nth-child(5) { grid-column: 2 / -1; } | |
| 728 | ||
| 729 | /* The floating chrome is positioned for a desktop margin: \`top: 16px\` with | |
| 730 | * 8px padding puts its lower edge at about 46px, and the page's own content | |
| 731 | * starts at 40px. On a wide screen the centred column leaves that gutter | |
| 732 | * empty so nothing shows; on a phone the column is the full width and the | |
| 733 | * header runs underneath the buttons. | |
| 734 | * | |
| 735 | * Smaller and tighter, so the chrome's lower edge lands near 32px and closes | |
| 736 | * the overlap window rather than merely making it less likely. */ | |
| 737 | .chrome-logout, .chrome-upgrade { | |
| 738 | top: 10px; | |
| 739 | padding: 5px 10px; | |
| 740 | font-size: 12px; | |
| 741 | border-radius: 8px; | |
| 742 | } | |
| 743 | ||
| 744 | .chrome-logout { right: 12px; } | |
| 745 | .chrome-upgrade { right: 88px; } | |
| 746 | } | |
| 747 | ` | |
| 748 | ||
| 749 | /* | |
| 750 | * The marketing surface, scoped to its layout group. | |
| 751 | * | |
| 752 | * From public/marketing.css, linked by resources/layouts/marketing.stx alone | |
| 753 | * -- never site-wide, because this sheet gives `.btn`, `.panel` and `.shell` | |
| 754 | * meanings the app pages contradict. That is also why it cannot be an | |
| 755 | * unscoped preflight the way TOKENS and APP_CHROME could: a preflight reaches | |
| 756 | * every page. It carries the layout group instead, which stx emits into <head> | |
| 757 | * from the layout's filename and stx-router keeps fresh across navigations. | |
| 758 | * Measured on the served app: marketing pages report `marketing`, and every | |
| 759 | * other route including /login reports `default`. | |
| 760 | * | |
| 761 | * WHAT WAS DROPPED, AND WHY IT IS SAFE | |
| 762 | * | |
| 763 | * Six colour tokens -- --accent, --bg, --border, --text, --text-2, --text-3 -- | |
| 764 | * were declared here AND in TOKENS, byte-identical in all four theme blocks, | |
| 765 | * so marketing pages inherit them unchanged. --mono went with them: it | |
| 766 | * differed from the TOKENS copy only by double quotes against single, which | |
| 767 | * CSS does not distinguish. | |
| 768 | * | |
| 769 | * --sans STAYS, and is the one token here that really differs: this copy has | |
| 770 | * `-apple-system` in the stack and the TOKENS copy does not. It only shows if | |
| 771 | * Space Grotesk fails to load, but keeping it means this move changes nothing. | |
| 772 | * | |
| 773 | * The marketing-only tokens stay by definition: --surface, --surface-2, | |
| 774 | * --border-strong, --accent-line, --code-bg, --code-ink, --code-cap, --r, | |
| 775 | * --r-sm. | |
| 776 | * | |
| 777 | * WHAT HAD TO BE SPELLED OUT | |
| 778 | * | |
| 779 | * Nesting puts the prefix in front of a descendant, so a rule rooted AT or | |
| 780 | * ABOVE <html> cannot nest -- `html:has(...) html` matches nothing, and :root | |
| 781 | * IS html. The four token blocks, the `html` background rule and `*` are | |
| 782 | * written out with the prefix; `*` becomes `P, P *` because it matched html | |
| 783 | * itself. `body` nests perfectly well, being below html, and is written out | |
| 784 | * only to keep the document-level rules together. | |
| 785 | * | |
| 786 | * THE CASCADE | |
| 787 | * | |
| 788 | * Unlike APP_CHROME, nothing here needed !important. Every rule above | |
| 789 | * single-class specificity was walked against the class lists in the marketing | |
| 790 | * views and checked against the generated sheet: of 31 candidates, not one | |
| 791 | * sits on an element whose other classes emit a utility setting the same | |
| 792 | * property. The pairs that look like collisions -- `.btn.primary`, `.cell.tint` | |
| 793 | * -- are two marketing classes, not a marketing class and a utility. | |
| 794 | */ | |
| 795 | const MARKETING = ` | |
| 796 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 797 | --surface: #ffffff; | |
| 798 | --surface-2: #f4f5f8; | |
| 799 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 800 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 801 | --code-bg: #0b0f19; | |
| 802 | --code-ink: #d6deeb; | |
| 803 | /* Caption ink for the code surface. Not --text-3: the code block is dark; | |
| 804 | * in BOTH themes, so the light --text-3 (#6b7280) lands on it at 3.96:1 — | |
| 805 | * the one WCAG AA failure left after the token pass. This clears 6.37:1 on | |
| 806 | * the light --code-bg and 6.70:1 on the dark one, so it needs no variant. */ | |
| 807 | --code-cap: #8b95ab; | |
| 808 | --r: 14px; | |
| 809 | --r-sm: 9px; | |
| 810 | --sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| 811 | } | |
| 812 | ||
| 813 | @media (prefers-color-scheme: dark) { | |
| 814 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 815 | --surface: #10131c; --surface-2: #161a24; | |
| 816 | --border-strong: rgba(148, 163, 184, 0.2); | |
| 817 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 818 | --code-bg: #05070c; | |
| 819 | } | |
| 820 | } | |
| 821 | html:has(meta[name="stx-layout-group"][content="marketing"])[data-theme="dark"] { | |
| 822 | --surface: #10131c; --surface-2: #161a24; | |
| 823 | --border-strong: rgba(148, 163, 184, 0.2); | |
| 824 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 825 | --code-bg: #05070c; | |
| 826 | } | |
| 827 | html:has(meta[name="stx-layout-group"][content="marketing"])[data-theme="light"] { | |
| 828 | --surface: #ffffff; --surface-2: #f4f5f8; | |
| 829 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 830 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 831 | --code-bg: #0b0f19; | |
| 832 | } | |
| 833 | ||
| 834 | html:has(meta[name="stx-layout-group"][content="marketing"]), html:has(meta[name="stx-layout-group"][content="marketing"]) * { box-sizing: border-box; } | |
| 835 | html:has(meta[name="stx-layout-group"][content="marketing"]) { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 836 | html:has(meta[name="stx-layout-group"][content="marketing"]) body { | |
| 837 | margin: 0; min-height: 100vh; overflow-x: hidden; | |
| 838 | color: var(--text); background: var(--bg); | |
| 839 | font-family: var(--sans); font-size: 16px; line-height: 1.5; | |
| 840 | -webkit-font-smoothing: antialiased; | |
| 841 | } | |
| 842 | ||
| 843 | html:has(meta[name="stx-layout-group"][content="marketing"]) { | |
| 844 | a { color: inherit; text-decoration: none; } | |
| 845 | ||
| 846 | .shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } | |
| 847 | .shell-narrow { width: min(920px, calc(100% - 48px)); margin: 0 auto; } | |
| 848 | .mono { font-family: var(--mono); } | |
| 849 | ||
| 850 | /* nav */ | |
| 851 | .nav { | |
| 852 | position: sticky; top: 0; z-index: 30; | |
| 853 | border-bottom: 1px solid var(--border); | |
| 854 | background: color-mix(in srgb, var(--bg) 82%, transparent); | |
| 855 | backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 856 | } | |
| 857 | .nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; min-height: 68px; } | |
| 858 | .brand { display: inline-flex; align-items: center; gap: 0.6rem; } | |
| 859 | .brand-mark { | |
| 860 | /* Mono for the glyph: Space Grotesk draws lowercase "l" as a bare vertical | |
| 861 | stroke, which reads as a capital "I" at 30px. */ | |
| 862 | font-family: var(--mono); | |
| 863 | display: grid; place-items: center; width: 30px; height: 30px; | |
| 864 | border: 1px solid var(--accent-line); border-radius: var(--r-sm); | |
| 865 | color: var(--accent); font-weight: 700; font-size: 1rem; line-height: 1; | |
| 866 | } | |
| 867 | .brand-name { font-weight: 700; font-size: 1.12rem; letter-spacing: -0.02em; } | |
| 868 | .nav-links { display: flex; align-items: center; gap: 1.6rem; margin-left: auto; color: var(--text-2); font-size: 0.9rem; } | |
| 869 | .nav-links a { transition: color 0.16s ease; } | |
| 870 | .nav-links a:hover { color: var(--text); } | |
| 871 | .nav-cta { margin-left: 1.6rem; } | |
| 872 | ||
| 873 | .nav-menu { display: none; margin-left: auto; position: relative; } | |
| 874 | .nav-menu > summary { | |
| 875 | list-style: none; display: grid; place-items: center; width: 40px; height: 40px; | |
| 876 | border: 1px solid var(--border-strong); border-radius: var(--r-sm); cursor: pointer; | |
| 877 | } | |
| 878 | .nav-menu > summary::-webkit-details-marker { display: none; } | |
| 879 | .nav-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 880 | .burger, .burger::before, .burger::after { | |
| 881 | display: block; width: 18px; height: 1.5px; background: var(--text); | |
| 882 | transition: transform 0.2s ease, opacity 0.2s ease; | |
| 883 | } | |
| 884 | .burger { position: relative; } | |
| 885 | .burger::before { content: ""; position: absolute; top: -5px; } | |
| 886 | .burger::after { content: ""; position: absolute; top: 5px; } | |
| 887 | .nav-menu[open] .burger { background: transparent; } | |
| 888 | .nav-menu[open] .burger::before { transform: translateY(5px) rotate(45deg); } | |
| 889 | .nav-menu[open] .burger::after { transform: translateY(-5px) rotate(-45deg); } | |
| 890 | .nav-menu-panel { | |
| 891 | position: absolute; right: 0; top: calc(100% + 12px); z-index: 50; | |
| 892 | display: flex; flex-direction: column; gap: 0.25rem; min-width: 232px; padding: 0.6rem; | |
| 893 | max-height: calc(100dvh - 92px); overflow-y: auto; overscroll-behavior: contain; | |
| 894 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 895 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 896 | } | |
| 897 | .nav-menu-panel a { padding: 0.6rem 0.7rem; border-radius: var(--r-sm); color: var(--text-2); font-size: 0.95rem; } | |
| 898 | .nav-menu-panel a:hover { color: var(--text); background: var(--surface-2); } | |
| 899 | .nav-menu-panel .btn { margin-top: 0.35rem; justify-content: center; } | |
| 900 | ||
| 901 | /* buttons */ | |
| 902 | .btn { | |
| 903 | display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; | |
| 904 | min-height: 46px; padding: 0 1.15rem; border: 1px solid var(--border-strong); border-radius: var(--r-sm); | |
| 905 | color: var(--text); font-family: var(--sans); font-weight: 600; font-size: 0.92rem; | |
| 906 | white-space: nowrap; background: transparent; cursor: pointer; | |
| 907 | transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; | |
| 908 | } | |
| 909 | .btn.sm { min-height: 38px; padding: 0 0.85rem; font-size: 0.86rem; } | |
| 910 | .btn.primary { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } | |
| 911 | .btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } | |
| 912 | .btn.ghost:hover { border-color: var(--accent-line); } | |
| 913 | .btn:active { transform: translateY(1px); } | |
| 914 | ||
| 915 | /* section scaffolding */ | |
| 916 | .section { padding: 3.5rem 0; } | |
| 917 | .section-head { max-width: 62ch; margin-bottom: 1.75rem; } | |
| 918 | .section-title { margin: 0; font-weight: 700; font-size: clamp(1.6rem, 3.5vw, 2.2rem); letter-spacing: -0.03em; line-height: 1.1; } | |
| 919 | .section-intro { margin: 0.9rem 0 0; color: var(--text-2); font-size: 1.02rem; line-height: 1.6; } | |
| 920 | .eyebrow { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 921 | .textlink { color: var(--accent); font-weight: 500; } | |
| 922 | .textlink:hover { text-decoration: underline; } | |
| 923 | ||
| 924 | /* hero */ | |
| 925 | .hero { display: grid; grid-template-columns: 1.05fr 1fr; gap: 3.5rem; align-items: center; padding: 3.5rem 0 4rem; } | |
| 926 | .hero-title { margin: 0; font-weight: 700; font-size: clamp(2.3rem, 5.4vw, 3.4rem); letter-spacing: -0.035em; line-height: 1.04; } | |
| 927 | .hero-sub { margin: 1.25rem 0 0; color: var(--text-2); font-size: 1.08rem; line-height: 1.6; max-width: 54ch; } | |
| 928 | .hero-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; } | |
| 929 | ||
| 930 | /* single-column feature/use-case hero */ | |
| 931 | .feat-hero { padding: 2.5rem 0 1.5rem; max-width: 62ch; } | |
| 932 | .feat-hero .kicker { display: inline-flex; align-items: center; gap: 0.5rem; font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 933 | .feat-hero h1 { margin: 1rem 0 0; font-weight: 700; font-size: clamp(2.1rem, 5vw, 3.1rem); line-height: 1.05; letter-spacing: -0.03em; } | |
| 934 | .feat-hero p { margin: 1.1rem 0 0; color: var(--text-2); font-size: 1.05rem; line-height: 1.6; } | |
| 935 | .feat-hero .hero-actions { margin-top: 1.75rem; } | |
| 936 | ||
| 937 | .subnav { display: flex; align-items: center; gap: 0.5rem; padding-top: 1.75rem; color: var(--text-3); font-size: 0.88rem; } | |
| 938 | .subnav a { color: var(--text-2); transition: color 0.16s ease; } | |
| 939 | .subnav a:hover { color: var(--text); } | |
| 940 | .subnav .sep { color: var(--text-3); } | |
| 941 | /* The trail is an <ol> so its order is exposed to assistive tech. The list and | |
| 942 | its items carry the flex layout the nav used to hold directly, so the visual | |
| 943 | result is unchanged. \`display: contents\` would be shorter and is avoided | |
| 944 | deliberately — several engines drop list items from the accessibility tree | |
| 945 | when it is applied, which would undo the reason for using a list. */ | |
| 946 | .subnav ol { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; } | |
| 947 | .subnav li { display: flex; align-items: center; gap: 0.5rem; } | |
| 948 | ||
| 949 | /* code panel */ | |
| 950 | .code { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 951 | .code-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.14); } | |
| 952 | .code-body { margin: 0; padding: 1.1rem 1.25rem; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: var(--code-ink); } | |
| 953 | .chart-cap { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 954 | /* Only on the code surface. \`.chart-cap\` is also used inside \`.panel-head\`, | |
| 955 | which sits on the normal page surface where --text-3 is the correct token. */ | |
| 956 | .code-bar .chart-cap { color: var(--code-cap); } | |
| 957 | ||
| 958 | /* panel (data viz container) */ | |
| 959 | .panel { border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); padding: 1.4rem 1.5rem; } | |
| 960 | .panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } | |
| 961 | .panel-tag { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 962 | .live { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-2); } | |
| 963 | .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | |
| 964 | .stat-big { margin: 0; font-weight: 700; font-size: 2.6rem; letter-spacing: -0.03em; } | |
| 965 | .stat-label { margin: 0.15rem 0 0; color: var(--text-3); font-size: 0.9rem; } | |
| 966 | ||
| 967 | /* trackless bars */ | |
| 968 | .bar-row { display: grid; grid-template-columns: 148px 1fr 52px; align-items: center; gap: 0.9rem; padding: 0.5rem 0; } | |
| 969 | .bar-row + .bar-row { border-top: 1px solid var(--border); } | |
| 970 | .bar-label { color: var(--text-2); font-size: 0.88rem; } | |
| 971 | .bar-track { height: 8px; border-radius: 999px; background: transparent; } | |
| 972 | .bar-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); opacity: 0.82; min-width: 4px; } | |
| 973 | .bar-value { font-family: var(--mono); font-size: 0.82rem; color: var(--text); text-align: right; } | |
| 974 | ||
| 975 | /* stat/severity tiles */ | |
| 976 | .vitals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 977 | .vital { padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 978 | .vital-k { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); } | |
| 979 | .vital-v { margin-top: 0.5rem; font-weight: 700; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--text); } | |
| 980 | .vital-tag { margin-top: 0.4rem; display: inline-flex; font-size: 0.78rem; color: var(--accent); } | |
| 981 | ||
| 982 | /* numbered flow */ | |
| 983 | .flow { display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 984 | .flow-step { display: grid; grid-template-columns: auto 1fr; gap: 1.1rem; padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 985 | .flow-num { font-family: var(--mono); font-size: 0.82rem; color: var(--accent); padding-top: 0.15rem; } | |
| 986 | .flow-step h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 987 | .flow-step p { margin: 0.4rem 0 0; color: var(--text-2); font-size: 0.95rem; line-height: 1.55; } | |
| 988 | ||
| 989 | /* metric tiles */ | |
| 990 | .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem 2.5rem; } | |
| 991 | .metric .m-value { font-weight: 700; font-size: clamp(1.8rem, 4vw, 2.4rem); letter-spacing: -0.02em; color: var(--text); } | |
| 992 | .metric .m-label { margin-top: 0.35rem; color: var(--text-2); font-size: 0.9rem; line-height: 1.5; } | |
| 993 | ||
| 994 | /* chips grid */ | |
| 995 | .chips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 996 | .chip { border: 1px solid var(--border); border-radius: var(--r); padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 997 | .chip h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 998 | .chip p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 999 | ||
| 1000 | /* feature bento (landing) */ | |
| 1001 | .bento { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1002 | .cell { display: block; padding: 1.5rem 1.6rem; background: var(--surface); transition: background 0.16s ease; } | |
| 1003 | a.cell { cursor: pointer; } | |
| 1004 | a.cell:hover { background: var(--surface-2); } | |
| 1005 | .cell h3 { margin: 0; font-weight: 600; font-size: 1.08rem; letter-spacing: -0.015em; } | |
| 1006 | .cell p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.93rem; line-height: 1.55; } | |
| 1007 | .cell.lead { grid-column: span 1; } | |
| 1008 | .cell.tint { background: var(--accent-soft); } | |
| 1009 | a.cell.tint:hover { background: color-mix(in srgb, var(--accent) 14%, var(--surface)); } | |
| 1010 | /* Featured bento cell: a soft rose wash (bughq accent) tinted into a corner, so | |
| 1011 | the grid has visual variation rather than six identical text cards. */ | |
| 1012 | .cell.feature { background: radial-gradient(120% 140% at 100% 0%, var(--accent-soft), transparent 58%), var(--surface); } | |
| 1013 | a.cell.feature:hover { background: radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%), var(--surface-2); } | |
| 1014 | .cell.feature h3 { color: var(--accent); } | |
| 1015 | .cell-more { display: inline-flex; align-items: center; gap: 0.35rem; margin-top: 1rem; font-family: var(--mono); font-size: 0.74rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); transition: color 0.16s ease, gap 0.16s ease; } | |
| 1016 | .cell-more::after { content: "\\2192"; } | |
| 1017 | a.cell:hover .cell-more { color: var(--accent); gap: 0.55rem; } | |
| 1018 | ||
| 1019 | /* comparison table */ | |
| 1020 | .cmp { display: grid; grid-template-columns: 1.1fr 1fr 1fr; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1021 | .cmp-cell { padding: 1rem 1.2rem; border-top: 1px solid var(--border); background: var(--surface); font-size: 0.92rem; } | |
| 1022 | .cmp-cell:nth-child(-n+3) { border-top: none; font-weight: 600; } | |
| 1023 | .cmp-dim { color: var(--text-3); } | |
| 1024 | .cmp-ghost { color: var(--text); } | |
| 1025 | ||
| 1026 | /* related features */ | |
| 1027 | .related { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 1028 | .related a { display: block; padding: 1.3rem 1.4rem; background: var(--surface); transition: background 0.16s ease; } | |
| 1029 | .related a:hover { background: var(--surface-2); } | |
| 1030 | .related .r-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: -0.01em; } | |
| 1031 | .related .r-desc { margin-top: 0.3rem; color: var(--text-3); font-size: 0.86rem; line-height: 1.45; } | |
| 1032 | ||
| 1033 | /* use-case cards */ | |
| 1034 | .usecases { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 1035 | .usecase { border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; background: var(--surface); } | |
| 1036 | .usecase img { display: block; width: 100%; height: 168px; object-fit: cover; } | |
| 1037 | .usecase-body { padding: 1.4rem 1.5rem 1.6rem; } | |
| 1038 | .usecase-body h2 { margin: 0; font-weight: 700; font-size: 1.15rem; letter-spacing: -0.02em; } | |
| 1039 | .usecase-body p { margin: 0.55rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 1040 | .usecase-body .textlink { margin-top: 0.9rem; display: inline-block; } | |
| 1041 | ||
| 1042 | /* cta band */ | |
| 1043 | .cta-band { border: 1px solid var(--border); border-radius: var(--r); padding: 2.5rem; background: var(--surface); text-align: center; } | |
| 1044 | .cta-band h2 { margin: 0 auto; max-width: 30ch; font-weight: 700; font-size: clamp(1.5rem, 3.5vw, 2rem); letter-spacing: -0.03em; } | |
| 1045 | .cta-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; } | |
| 1046 | ||
| 1047 | /* subscribe form | |
| 1048 | Sized to the band's own rhythm: the input borrows the .btn min-height so the | |
| 1049 | field and its button line up without a magic number, and the row collapses to | |
| 1050 | a stack under 30rem rather than squeezing a 40-character field. */ | |
| 1051 | .sub-form { display: flex; flex-wrap: wrap; gap: 0.6rem; justify-content: center; margin: 1.5rem auto 0; max-width: 27rem; } | |
| 1052 | .sub-field { flex: 1 1 15rem; min-width: 0; min-height: 44px; padding: 0 0.9rem; border: 1px solid var(--border-strong, var(--border)); border-radius: calc(var(--r) - 4px); background: var(--bg); color: var(--text); font: inherit; font-size: 0.95rem; transition: border-color 0.16s ease, box-shadow 0.16s ease; } | |
| 1053 | .sub-field::placeholder { color: var(--text-3); } | |
| 1054 | .sub-field:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 1055 | /* Defined here rather than assumed from the utility layer: this stylesheet is | |
| 1056 | the page's own contract, and a visually-hidden helper that silently is not | |
| 1057 | emitted shows up as a stray label sitting above the field. */ | |
| 1058 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 1059 | .sub-note { margin: 0.85rem auto 0; max-width: 40ch; color: var(--text-3); font-size: 0.84rem; line-height: 1.5; } | |
| 1060 | .sub-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 2px; } | |
| 1061 | /* One line, reserved by min-height so a reply does not shift the section. */ | |
| 1062 | .sub-status { margin: 0.85rem auto 0; min-height: 1.25rem; color: var(--text-2); font-size: 0.88rem; } | |
| 1063 | .sub-status[data-state="error"] { color: var(--accent); } | |
| 1064 | @media (max-width: 30rem) { | |
| 1065 | .sub-form { flex-direction: column; } | |
| 1066 | /* A row's 15rem width basis becomes height when the form stacks. */ | |
| 1067 | .sub-field { flex: none; } | |
| 1068 | .sub-form .btn { width: 100%; justify-content: center; } | |
| 1069 | } | |
| 1070 | ||
| 1071 | /* footer */ | |
| 1072 | .footer { padding: 2.5rem 0; } | |
| 1073 | .footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--border); color: var(--text-3); font-size: 0.88rem; } | |
| 1074 | ||
| 1075 | /* motion — \`.reveal\` is NOT declared here. | |
| 1076 | * | |
| 1077 | * It used to be, and app-chrome.css declared it too: two \`.reveal\` rules and | |
| 1078 | * two identically-named \`@keyframes reveal\` in two sheets that are both loaded | |
| 1079 | * on every marketing page. This file wins on order, so marketing pages animated | |
| 1080 | * over 0.6s/12px and the dashboard — the one app page that uses .reveal, and | |
| 1081 | * which never loads this file — animated over 0.5s/10px. Nothing intended that | |
| 1082 | * split; it was drift between two copies. | |
| 1083 | * | |
| 1084 | * The single definition lives in app-chrome.css, which is the site-wide sheet, | |
| 1085 | * and it carries these values because 23 marketing pages used them against the | |
| 1086 | * dashboard's one. A same-named @keyframes in a later sheet REPLACES rather | |
| 1087 | * than merges, so the duplicate was also a live footgun: editing the copy here | |
| 1088 | * would have silently changed marketing pages only. | |
| 1089 | */ | |
| 1090 | ||
| 1091 | /* responsive */ | |
| 1092 | @media (max-width: 900px) { | |
| 1093 | body:has(.nav-menu[open]) { overflow: hidden; } | |
| 1094 | .hero { grid-template-columns: 1fr; gap: 2rem; padding-top: 2.5rem; } | |
| 1095 | .bento, .related, .cmp { grid-template-columns: 1fr; } | |
| 1096 | .metrics { grid-template-columns: 1fr 1fr; } | |
| 1097 | .chips, .usecases { grid-template-columns: 1fr; } | |
| 1098 | .nav-links { display: none; } | |
| 1099 | .nav-cta { display: none; } | |
| 1100 | .nav-menu { display: block; } | |
| 1101 | } | |
| 1102 | @media (max-width: 560px) { | |
| 1103 | .metrics, .vitals { grid-template-columns: 1fr; } | |
| 1104 | .flow-step { grid-template-columns: 1fr; gap: 0.4rem; } | |
| 1105 | .bar-row { grid-template-columns: 120px 1fr 44px; } | |
| 1106 | } | |
| 1107 | ||
| 1108 | /* login link beside the Sign up nav button */ | |
| 1109 | .nav-login { color: var(--text-2); font-size: 0.9rem; transition: color 0.16s ease; } | |
| 1110 | .nav-login:hover { color: var(--text); } | |
| 1111 | .nav-cta { margin-left: 0.95rem; } | |
| 1112 | ||
| 1113 | /* CSS-only mega menu, from SiteNav.stx's <style> block. It lives here rather | |
| 1114 | * than in the partial because none of what follows is expressible as a | |
| 1115 | * utility: two pseudo-elements (the caret and the invisible hover bridge that | |
| 1116 | * lets the pointer cross the 14px gap), ancestor-state descendants | |
| 1117 | * (\`.mega:hover .mega-panel\`), a \`:not(:first-child)\`, and a max-width media | |
| 1118 | * query — Crosswind emits nothing for any of them. The leaf styling that IS | |
| 1119 | * expressible (.mega-t, .mega-d, the panel links, the menu group's own box) | |
| 1120 | * became utilities in the markup and is deliberately absent here. | |
| 1121 | * | |
| 1122 | * A linked stylesheet is also the router-safe home: the SPA router destroys | |
| 1123 | * every <head> style that is not the Crosswind tag on navigation. | |
| 1124 | */ | |
| 1125 | .mega { position: relative; display: inline-flex; align-items: center; } | |
| 1126 | .mega-trigger { display: inline-flex; align-items: center; gap: 0.32rem; cursor: pointer; } | |
| 1127 | .mega-trigger::after { | |
| 1128 | content: "\\25BE"; font-size: 0.62em; line-height: 1; color: var(--text-3); | |
| 1129 | transition: transform 0.18s ease, color 0.18s ease; | |
| 1130 | } | |
| 1131 | .mega:hover .mega-trigger, .mega:focus-within .mega-trigger { color: var(--text); } | |
| 1132 | .mega:hover .mega-trigger::after, .mega:focus-within .mega-trigger::after { transform: rotate(180deg); color: var(--accent); } | |
| 1133 | ||
| 1134 | .mega-panel { | |
| 1135 | position: absolute; top: 100%; left: 50%; z-index: 50; | |
| 1136 | margin-top: 14px; | |
| 1137 | display: grid; grid-template-columns: repeat(2, minmax(216px, 1fr)); gap: 0.12rem; | |
| 1138 | min-width: 484px; padding: 0.55rem; | |
| 1139 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 1140 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 1141 | opacity: 0; visibility: hidden; pointer-events: none; | |
| 1142 | transform: translateX(-50%) translateY(6px); | |
| 1143 | transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; | |
| 1144 | } | |
| 1145 | .mega-panel::before { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; } | |
| 1146 | .mega:hover .mega-panel, .mega:focus-within .mega-panel { | |
| 1147 | opacity: 1; visibility: visible; pointer-events: auto; | |
| 1148 | transform: translateX(-50%) translateY(0); | |
| 1149 | } | |
| 1150 | .nav-menu-group:not(:first-child) { margin-top: 0.35rem; border-top: 1px solid var(--border); } | |
| 1151 | ||
| 1152 | @media (prefers-reduced-motion: reduce) { | |
| 1153 | .mega-trigger::after, .mega-panel { transition: none; } | |
| 1154 | } | |
| 1155 | @media (max-width: 900px) { | |
| 1156 | /* desktop mega hidden with .nav-links; the <details> menu takes over */ | |
| 1157 | .mega-panel { display: none; } | |
| 1158 | } | |
| 1159 | } | |
| 1160 | ` | |
| 1161 | ||
| 1162 | /* | |
| 1163 | * The date-range picker's stylesheet, lifted out of a <style> block in | |
| 1164 | * resources/views/analytics.stx (stx-standards rule 3). | |
| 1165 | * | |
| 1166 | * This is a RELOCATION and not a conversion to utilities, which is a weaker | |
| 1167 | * outcome than the rule wants, so here is why it is the honest one. Every | |
| 1168 | * state in this widget is a compound selector the client script toggles -- | |
| 1169 | * .drp-day.is-range, .drp-preset.is-active, .drp-nav.is-hidden -- and an | |
| 1170 | * ordinary utility cannot express "this class AND that class". | |
| 1171 | * | |
| 1172 | * The spelling that can, an arbitrary-variant utility like | |
| 1173 | * [&.is-range]:bg-accent, used to emit nothing: the extractor rejected any | |
| 1174 | * class whose variant is an arbitrary selector (stacksjs/ts-css#37). **Fixed | |
| 1175 | * in 0.3.16**, which this app now resolves -- measured through this app's own | |
| 1176 | * loader and merge, so against this config: | |
| 1177 | * | |
| 1178 | * [&.is-range]:bg-accent -> .….is-range { background-color: var(--accent) } | |
| 1179 | * [&.drp-day.is-range]:bg-accent -> .….drp-day.is-range { … } | |
| 1180 | * [&.is-hidden]:hidden -> .….is-hidden { display: none } | |
| 1181 | * | |
| 1182 | * So a conversion is possible now. Two reasons it still has not happened, and | |
| 1183 | * neither is the defect. First, this stylesheet is shared with bughq byte for | |
| 1184 | * byte, and converting it on one side forks it. Second, a PARTIAL conversion | |
| 1185 | * is actively worse than either end state: the shortcuts are unlayered and a | |
| 1186 | * preflight lands in @layer cw-base, so every unlayered base would outrank its | |
| 1187 | * own layered active state and the selected range would stop painting. | |
| 1188 | * | |
| 1189 | * One entry, not one per rule. An earlier version split it, on the strength of | |
| 1190 | * a measurement taken against @cwcss/crosswind -- which this file imports its | |
| 1191 | * TYPES from and which does not render this app. stx loads @stacksjs/ts-css on | |
| 1192 | * every path, and that engine keeps every rule in a multi-rule getCSS(): all 35 | |
| 1193 | * here, and the 80 bughq passes through its own pageCss(). The split was | |
| 1194 | * guarding against a bug in an engine that is not in the loop. | |
| 1195 | * | |
| 1196 | * There IS a real preflight hazard, and it is about quoting rather than | |
| 1197 | * structure: a DOUBLE-quoted font family corrupts the sheet. On ts-css, | |
| 1198 | * `font-family:...,"Segoe UI",sans-serif` came back as the engine's own default | |
| 1199 | * stack with everything up to the next `transform` swallowed, taking a whole | |
| 1200 | * h1-h3 rule and half a @keyframes block with it. This stylesheet quotes no | |
| 1201 | * families, so it is unaffected; commshq's tokens use single quotes for it. | |
| 1202 | * | |
| 1203 | * Moving it into the layer was checked for cascade damage rather than assumed. | |
| 1204 | * The picker's markup carries component classes only -- no utility co-occurs | |
| 1205 | * with .range-tool, .drp-* or .date-popover -- so nothing of equal specificity | |
| 1206 | * now wins where it used to lose. The one place a utility does meet these | |
| 1207 | * rules is the shared :focus-visible list: settings.stx puts | |
| 1208 | * focus:outline-none on seven inputs, and that already beat input:focus-visible | |
| 1209 | * on specificity (0,2,0 against 0,1,1) before this move. Those fields trade | |
| 1210 | * the ring for a border colour deliberately, and they behave the same either | |
| 1211 | * way. | |
| 1212 | */ | |
| 1213 | const DATE_RANGE_PICKER_CSS = ` | |
| 1214 | .range-tool { | |
| 1215 | display: inline-flex; | |
| 1216 | align-items: center; | |
| 1217 | justify-content: center; | |
| 1218 | gap: 0.4rem; | |
| 1219 | min-height: 2.25rem; | |
| 1220 | padding: 0 0.72rem; | |
| 1221 | border: 1px solid var(--border); | |
| 1222 | border-radius: 9px; | |
| 1223 | background: var(--panel); | |
| 1224 | color: var(--text-2); | |
| 1225 | font: 600 0.8125rem/1 var(--sans, ui-sans-serif); | |
| 1226 | text-decoration: none; | |
| 1227 | cursor: pointer; | |
| 1228 | transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; | |
| 1229 | } | |
| 1230 | .range-tool:hover { color: var(--text); border-color: var(--accent); } | |
| 1231 | .range-tool:active { transform: scale(0.98); } | |
| 1232 | .range-tool:focus-visible, | |
| 1233 | .range-link:focus-visible, | |
| 1234 | .chrome-btn:focus-visible, | |
| 1235 | .icon-btn:focus-visible, | |
| 1236 | select.panel:focus-visible, | |
| 1237 | input:focus-visible, | |
| 1238 | textarea:focus-visible, | |
| 1239 | summary:focus-visible { | |
| 1240 | outline: 2px solid var(--accent); | |
| 1241 | outline-offset: 2px; | |
| 1242 | } | |
| 1243 | .range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 1244 | .range-tool i { width: 1rem; height: 1rem; } | |
| 1245 | .date-menu { position: relative; } | |
| 1246 | .date-menu > summary { list-style: none; } | |
| 1247 | .date-menu > summary::-webkit-details-marker { display: none; } | |
| 1248 | .date-popover { | |
| 1249 | position: absolute; | |
| 1250 | right: 0; | |
| 1251 | top: calc(100% + 0.5rem); | |
| 1252 | z-index: 40; | |
| 1253 | width: min(44rem, calc(100vw - 2.5rem)); | |
| 1254 | padding: 0.75rem; | |
| 1255 | border: 1px solid var(--border); | |
| 1256 | border-radius: 12px; | |
| 1257 | background: var(--panel); | |
| 1258 | box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent); | |
| 1259 | } | |
| 1260 | /* Period picker: presets down the left, two months on the right. The range is | |
| 1261 | drawn flat with rounded ends, the way Fathom's is, on the --bar wash the KPI | |
| 1262 | bars already use so it reads as the same accent at the same strength. */ | |
| 1263 | .drp-backdrop { position: fixed; inset: 0; z-index: 39; background: transparent; cursor: default; } | |
| 1264 | .drp { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 0.75rem; } | |
| 1265 | .drp-presets { display: flex; flex-direction: column; gap: 0.15rem; padding-right: 0.75rem; border-right: 1px solid var(--border); } | |
| 1266 | .drp-preset { | |
| 1267 | display: block; | |
| 1268 | width: 100%; | |
| 1269 | padding: 0.5rem 0.7rem; | |
| 1270 | border: 0; | |
| 1271 | border-radius: 8px; | |
| 1272 | background: transparent; | |
| 1273 | color: var(--text-2); | |
| 1274 | font: 500 0.8125rem/1.2 var(--sans, ui-sans-serif); | |
| 1275 | text-align: left; | |
| 1276 | cursor: pointer; | |
| 1277 | } | |
| 1278 | .drp-preset:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); } | |
| 1279 | .drp-preset.is-active { color: var(--accent-ink); background: var(--accent); } | |
| 1280 | .drp-cals { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } | |
| 1281 | .drp-head { display: grid; grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 2rem; align-items: center; gap: 0.35rem; margin-bottom: 0.5rem; } | |
| 1282 | .drp-nav { | |
| 1283 | display: inline-grid; | |
| 1284 | place-items: center; | |
| 1285 | width: 2rem; | |
| 1286 | height: 2rem; | |
| 1287 | padding: 0; | |
| 1288 | border: 1px solid transparent; | |
| 1289 | border-radius: 8px; | |
| 1290 | background: transparent; | |
| 1291 | color: var(--text-2); | |
| 1292 | cursor: pointer; | |
| 1293 | } | |
| 1294 | .drp-nav:hover { color: var(--text); border-color: var(--border); } | |
| 1295 | .drp-nav svg { width: 1rem; height: 1rem; } | |
| 1296 | .drp-nav.is-hidden { visibility: hidden; } | |
| 1297 | .drp-sel { | |
| 1298 | min-width: 0; | |
| 1299 | height: 2rem; | |
| 1300 | padding: 0 0.5rem; | |
| 1301 | border: 1px solid var(--border); | |
| 1302 | border-radius: 8px; | |
| 1303 | background: var(--bg); | |
| 1304 | color: var(--text); | |
| 1305 | font: 600 0.8125rem/1 var(--sans, ui-sans-serif); | |
| 1306 | } | |
| 1307 | .drp-dow, .drp-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } | |
| 1308 | .drp-dow span { padding: 0.25rem 0; color: var(--text-3); font: 600 0.7rem/1.2 var(--sans, ui-sans-serif); text-align: center; } | |
| 1309 | .drp-day { | |
| 1310 | height: 2.25rem; | |
| 1311 | padding: 0; | |
| 1312 | border: 0; | |
| 1313 | border-radius: 0; | |
| 1314 | background: transparent; | |
| 1315 | color: var(--text); | |
| 1316 | font: 500 0.8125rem/1 var(--mono, ui-monospace); | |
| 1317 | cursor: pointer; | |
| 1318 | } | |
| 1319 | .drp-day:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 1320 | .drp-day.is-out { color: var(--text-3); } | |
| 1321 | .drp-day.is-today { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; } | |
| 1322 | .drp-day.is-range { background: var(--accent-soft); } | |
| 1323 | .drp-day.is-edge { background: var(--accent); color: var(--accent-ink); border-radius: 8px; } | |
| 1324 | .drp-day.is-disabled { color: var(--text-3); opacity: 0.45; cursor: default; } | |
| 1325 | .drp-day.is-disabled:hover { background: transparent; } | |
| 1326 | .drp-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; } | |
| 1327 | @media (max-width: 1023px) { | |
| 1328 | .date-menu { position: static; } | |
| 1329 | .date-popover { left: 0; right: auto; width: calc(100vw - 2rem); max-height: calc(100dvh - 6rem); overflow-y: auto; overscroll-behavior: contain; } | |
| 1330 | .drp { grid-template-columns: minmax(0, 1fr); } | |
| 1331 | .drp-presets { flex-direction: row; flex-wrap: wrap; gap: 0.35rem; padding: 0 0 0.6rem; border-right: 0; border-bottom: 1px solid var(--border); } | |
| 1332 | .drp-preset { width: auto; padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: 999px; } | |
| 1333 | .drp-cals { grid-template-columns: minmax(0, 1fr); } | |
| 1334 | .drp-cal.is-right { display: none; } | |
| 1335 | .drp-cal.is-left .drp-nav.is-hidden { visibility: visible; } | |
| 1336 | .drp-day { height: 2.5rem; } | |
| 1337 | } | |
| 1338 | ` | |
| 1339 | ||
| 46 | 1340 | export default { |
| 1341 | preflights: [ | |
| 1342 | // In the order the three stylesheets they came from were linked: | |
| 1343 | // tokens, then the app chrome that reads them, then page CSS. | |
| 1344 | { getCSS: () => TOKENS }, | |
| 1345 | { getCSS: () => APP_CHROME }, | |
| 1346 | // Scoped to the marketing layout group; everything above is site-wide. | |
| 1347 | // After TOKENS so its own --sans wins where the two differ. | |
| 1348 | { getCSS: () => MARKETING }, | |
| 1349 | { getCSS: () => DATE_RANGE_PICKER_CSS }, | |
| 1350 | ], | |
| 47 | 1351 | theme: { |
| 48 | 1352 | extend: { |
| 49 | 1353 | borderRadius: { |
| @@ -101,7 +1405,22 @@ export default { | ||
| 101 | 1405 | 'fg': 'var(--text)', |
| 102 | 1406 | 'fg-strong': 'var(--text)', |
| 103 | 1407 | 'fg-muted': 'var(--text-2)', |
| 104 | 'fg-soft': 'var(--text-2)', | |
| 1408 | /* | |
| 1409 | * The library's quiet text scale is four steps - fg, fg-muted, fg-soft, | |
| 1410 | * fg-subtle - and this palette has three, so two roles share a value. | |
| 1411 | * They used to share at the WRONG end: fg-soft was byte-identical to | |
| 1412 | * fg-muted, which makes "quieter than muted" exactly as loud as muted. | |
| 1413 | * | |
| 1414 | * Nothing renders it today, which is why it was invisible: of the 53 | |
| 1415 | * <EmptyState> sites in the estate none passes an `icon` (the only | |
| 1416 | * thing there that reads fg-soft) and there is no <Badge dot>. But | |
| 1417 | * <Breadcrumb> paints separators with fg-soft and links with fg-muted, | |
| 1418 | * so the two being equal is a divider as loud as the labels it divides. | |
| 1419 | * | |
| 1420 | * Doubling up at the quiet end keeps the scale monotonic, so a | |
| 1421 | * component asking for a quieter grey gets one. | |
| 1422 | */ | |
| 1423 | 'fg-soft': 'var(--text-3)', | |
| 105 | 1424 | 'fg-subtle': 'var(--text-3)', |
| 106 | 1425 | 'line-strong': 'color-mix(in srgb, var(--text-3) 55%, var(--border))', |
| 107 | 1426 | 'line-hover': 'color-mix(in srgb, var(--text-2) 70%, var(--border))', |
| @@ -192,10 +1511,119 @@ export default { | ||
| 192 | 1511 | + 'focus:outline-none focus:border-accent ' |
| 193 | 1512 | + 'focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_16%,transparent)]', |
| 194 | 1513 | |
| 195 | 'btn-solid': 'bg-accent text-accent-ink rounded-[10px] ' | |
| 196 | + '[transition:transform_0.12s_ease,opacity_0.15s_ease] ' | |
| 197 | + 'hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] ' | |
| 198 | + 'active:translate-y-px disabled:opacity-60 disabled:cursor-default', | |
| 1514 | /* | |
| 1515 | * The union of the transition lists, not this app's first one. | |
| 1516 | * | |
| 1517 | * This entry animated transform and opacity only, so the accent fill it | |
| 1518 | * sets on :hover SNAPPED. settings.stx's two solid buttons declared | |
| 1519 | * `background 0.15s ease, opacity 0.15s ease` instead and eased it, which | |
| 1520 | * is how the omission surfaced when they were folded in here. Each copy had | |
| 1521 | * named the properties its own call site happened to animate; the union is | |
| 1522 | * what all of them meant. bughq's btn-solid took the same union for the | |
| 1523 | * same reason. | |
| 1524 | */ | |
| 1525 | 'btn-solid': 'btn-focus bg-accent !text-accent-ink !rounded-[10px] ' | |
| 1526 | + '![transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease] ' | |
| 1527 | + 'hover:!bg-[color-mix(in_srgb,var(--accent)_88%,#000)] ' | |
| 1528 | + 'active:translate-y-px disabled:!opacity-60 disabled:!cursor-default', | |
| 1529 | ||
| 1530 | /* | |
| 1531 | * ## settings.stx's button vocabulary | |
| 1532 | * | |
| 1533 | * Three paints and four sizes, which is what its 23 raw `<button>` | |
| 1534 | * elements actually were. Measured class string by class string: 14 | |
| 1535 | * distinct strings collapsed to these, with no string left over and no | |
| 1536 | * paint invented. The four controls that are NOT ordinary buttons -- the | |
| 1537 | * theme toggle (`icon-btn`), the channel switch (`tgl`) and the two | |
| 1538 | * segment buttons (`seg`) -- keep their own shortcuts and are untouched. | |
| 1539 | * | |
| 1540 | * Marked `!important` throughout, and this is the reason rather than a | |
| 1541 | * habit. The next step puts `<Button variant="ghost">` behind these, and | |
| 1542 | * the component's own `variantClasses.ghost` and `sizeClasses.md` are | |
| 1543 | * single-class selectors exactly like a shortcut: specificity ties, and | |
| 1544 | * crosswind sorts its output by category rather than by generation order, | |
| 1545 | * so an unmarked override lands PARTIALLY. Half an override is worse than | |
| 1546 | * none, because it reads as working. bughq marked its family for the same | |
| 1547 | * reason and the comment there has the measurement. | |
| 1548 | * | |
| 1549 | * Marked across the whole family, including the `:hover` steps: a marked | |
| 1550 | * base outranks its own unmarked hover. | |
| 1551 | * | |
| 1552 | * The weight lives with the size, not the paint, because the two sizes | |
| 1553 | * that exist in both weights differ ONLY in weight: `btn-md` is the | |
| 1554 | * modal's Cancel and `btn-cta` is every primary action, and both are | |
| 1555 | * px-4 py-2.5 text-sm. | |
| 1556 | */ | |
| 1557 | 'btn-quiet': 'btn-focus btn-disabled bg-panel border border-line !text-muted ' | |
| 1558 | + '!rounded-[9px] ![transition:color_0.15s_ease,border-color_0.15s_ease] ' | |
| 1559 | + 'hover:!bg-panel hover:!text-ink ' | |
| 1560 | + 'hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]', | |
| 1561 | ||
| 1562 | 'btn-danger': 'btn-focus btn-disabled bg-panel border !border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] ' | |
| 1563 | + '!text-warn !rounded-[9px] ![transition:all_0.15s_ease] ' | |
| 1564 | + 'hover:!text-accent-ink hover:!bg-warn hover:!border-warn', | |
| 1565 | ||
| 1566 | // The copy button that sits over a code block. Its own entry rather than a | |
| 1567 | // size of btn-quiet: it is positioned, uses the sans stack against the | |
| 1568 | // mono block it overlays, and is the only control in the file at 11px. | |
| 1569 | 'btn-copy': 'btn-focus btn-disabled absolute right-2 top-2 bg-panel border border-line ' | |
| 1570 | + '!px-[9px] !py-[5px] !text-[11px] !font-medium font-sans !leading-none ' | |
| 1571 | + '!text-subtle cursor-pointer !rounded-[7px] ' | |
| 1572 | + '![transition:color_0.15s_ease,border-color_0.15s_ease] ' | |
| 1573 | + 'hover:!bg-panel hover:!text-ink ' | |
| 1574 | + 'hover:!border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]', | |
| 1575 | ||
| 1576 | /* | |
| 1577 | * The app's own focus treatment, restored over <Button>'s. | |
| 1578 | * | |
| 1579 | * The component declares `focus:outline-none focus:ring-2 ...`, and a | |
| 1580 | * utility is unlayered while this app's `button:focus-visible { outline: | |
| 1581 | * 2px solid var(--accent) }` is a preflight in @layer tc-base -- so the | |
| 1582 | * component's ring wins at any specificity and the app's outline simply | |
| 1583 | * stops arriving. The two look nearly identical, but the component's fires | |
| 1584 | * on `:focus` and the app's on `:focus-visible`, so adopting <Button> | |
| 1585 | * would have put a ring on nine buttons after a MOUSE click while every | |
| 1586 | * other control in the app rings only for the keyboard. | |
| 1587 | * | |
| 1588 | * `focus:!ring-0` cancels the component's ring and the `:focus-visible` | |
| 1589 | * pair restores the preflight's rule at utility level, where it outranks | |
| 1590 | * the component. | |
| 1591 | * | |
| 1592 | * This spelling -- a variant on an arbitrary property -- emitted NOTHING | |
| 1593 | * until ts-css 0.3.16 (stacksjs/ts-css#36 for the generator, #37 for the | |
| 1594 | * extractor). It is the first place in this app that needed it, and it was | |
| 1595 | * measured through this app's own loader before being relied on. | |
| 1596 | */ | |
| 1597 | 'btn-focus': 'focus:!ring-0 ' | |
| 1598 | + 'focus-visible:![outline:2px_solid_var(--accent)] ' | |
| 1599 | + 'focus-visible:![outline-offset:2px]', | |
| 1600 | ||
| 1601 | /* | |
| 1602 | * One disabled appearance for every button. | |
| 1603 | * | |
| 1604 | * <Button> brings `disabled:opacity-50 disabled:cursor-not-allowed`, and | |
| 1605 | * btn-solid already declared 60% with a default cursor. Rather than let | |
| 1606 | * three paints disagree, all of them take the 60% that btn-solid's call | |
| 1607 | * sites have used all along. | |
| 1608 | * | |
| 1609 | * This is also a gain on the quiet and danger paints, which previously had | |
| 1610 | * no disabled styling at all: `:disabled="rotating()"` dimmed nothing, so a | |
| 1611 | * disabled Rotate button looked exactly like an enabled one. | |
| 1612 | */ | |
| 1613 | 'btn-disabled': 'disabled:!opacity-60 disabled:!cursor-default', | |
| 1614 | ||
| 1615 | 'btn-xs': '!px-2.5 !py-1.5 !text-xs !font-medium', | |
| 1616 | 'btn-sm': '!px-3 !py-2 !text-sm !font-medium', | |
| 1617 | 'btn-md': '!px-4 !py-2.5 !text-sm !font-medium', | |
| 1618 | 'btn-cta': '!px-4 !py-2.5 !text-sm !font-semibold', | |
| 1619 | ||
| 1620 | // Shared Button owns semantics and reactive state. These explicit | |
| 1621 | // overrides preserve the existing auth controls without depending on | |
| 1622 | // utility order or copying the component into the app. | |
| 1623 | 'auth-submit': '!py-2.5 !shadow-none ' | |
| 1624 | + '![transition:transform_0.12s_ease,opacity_0.15s_ease] ' | |
| 1625 | + 'hover:!bg-[color-mix(in_srgb,var(--accent)_88%,#000)] ' | |
| 1626 | + 'active:translate-y-px disabled:!opacity-60 disabled:!cursor-default', | |
| 199 | 1627 | |
| 200 | 1628 | 'oauth-btn': 'bg-panel border border-line rounded-[10px] text-ink ' |
| 201 | 1629 | + '[transition:border-color_0.15s_ease,transform_0.12s_ease] ' |
| @@ -219,6 +1647,22 @@ export default { | ||
| 219 | 1647 | 'err-box': 'bg-[color-mix(in_srgb,#ef4444_12%,transparent)] text-[#ef4444]', |
| 220 | 1648 | 'ok-box': 'bg-[color-mix(in_srgb,#22c55e_12%,transparent)] text-[#16a34a]', |
| 221 | 1649 | |
| 1650 | /* | |
| 1651 | * The box those two sit in. Ten sites across six views spelled | |
| 1652 | * `px-3 text-sm rounded-lg` out as three separate utilities, identically in | |
| 1653 | * every one, beside whichever of the pair gave it a colour. | |
| 1654 | * | |
| 1655 | * Kept apart from err-box and ok-box rather than folded into them, so the | |
| 1656 | * composite reads the same way in all three apps that have it: colour is | |
| 1657 | * one class, geometry is another, and `class="mb-4 py-2 * err-box notice-box"` says which is which. It also means the confirmation and the | |
| 1658 | * error share a box instead of each carrying their own copy of it. | |
| 1659 | * | |
| 1660 | * The vertical padding stays at the call site: errors sit at py-2 and | |
| 1661 | * confirmations at py-3 here, in bughq and in analyticshq alike, so it is a | |
| 1662 | * real axis rather than an oversight. | |
| 1663 | */ | |
| 1664 | 'notice-box': 'px-3 text-sm rounded-lg', | |
| 1665 | ||
| 222 | 1666 | // SiteFooter's repeated link and column heading. Four columns and ~28 |
| 223 | 1667 | // links, so these earn a name rather than being repeated on each element. |
| 224 | 1668 | 'footer-head': 'm-0 mb-[0.35rem] font-mono text-[0.7rem] font-semibold ' |
| @@ -211,23 +211,16 @@ export default { | ||
| 211 | 211 | // /dashboard and /settings, which define their own conflicting versions of |
| 212 | 212 | // the same class names. It lives in resources/layouts/marketing.stx |
| 213 | 213 | // instead, so it arrives with the shell that needs it. |
| 214 | // | |
| 215 | // /tokens.css and /app-chrome.css used to be linked here and are gone: | |
| 216 | // both are `preflights` entries in config/crosswind.ts now, emitted into | |
| 217 | // the generated sheet (stx-standards rule 3). Nothing replaced them in | |
| 218 | // this list, because the generated sheet is injected by the renderer. | |
| 214 | 219 | link: [ |
| 215 | 220 | { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, |
| 216 | 221 | { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, |
| 217 | 222 | { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, |
| 218 | 223 | { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap' }, |
| 219 | // Design tokens, once. Ten app views each carried their own copy of | |
| 220 | // this block. Deliberately BEFORE /marketing.css, which the marketing | |
| 221 | // layout adds and which defines a richer overlapping set — later wins, | |
| 222 | // which is the intent. | |
| 223 | { rel: 'stylesheet', href: '/tokens.css' }, | |
| 224 | // App-surface CSS that utilities provably cannot express — | |
| 225 | // pseudo-elements, attribute-state selectors, bare element selectors, | |
| 226 | // @keyframes, and the dashboard's page-scoped accent. After | |
| 227 | // /tokens.css so it can read the palette; still before the Crosswind | |
| 228 | // sheet, which the renderer injects last, so a utility continues to | |
| 229 | // win a specificity tie against anything in here. | |
| 230 | { rel: 'stylesheet', href: '/app-chrome.css' }, | |
| 231 | 224 | ], |
| 232 | 225 | }, |
| 233 | 226 | }, |
| @@ -3,8 +3,20 @@ | ||
| 3 | 3 | -- one (stacksjs/stacks#2346: it either keeps the postgres files or writes 80 |
| 4 | 4 | -- migrations for 78 framework models). Fixed upstream, unreleased. Regenerate |
| 5 | 5 | -- and delete this note once it ships. |
| 6 | -- | |
| 7 | -- AUTOINCREMENT dropped, nothing else touched. This app now runs postgres, and | |
| 8 | -- `buddy migrate` refuses the ENTIRE corpus when any file is sqlite-only -- so | |
| 9 | -- that one keyword left loghq unable to apply any new migration at all. Bare | |
| 10 | -- `INTEGER PRIMARY KEY` parses in both dialects and is still a rowid alias in | |
| 11 | -- sqlite, so it still auto-assigns; the only thing given up is AUTOINCREMENT's | |
| 12 | -- guarantee never to reuse a deleted id. | |
| 13 | -- | |
| 14 | -- Nothing here is rewritten into postgres DDL, because these files did not | |
| 15 | -- build the live postgres schema: users.id there is backed by users_id_seq, a | |
| 16 | -- sequence this DDL never creates, and 80 migrations are recorded as applied | |
| 17 | -- against 20 files on disk. | |
| 6 | 18 | CREATE TABLE IF NOT EXISTS "users" ( |
| 7 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 19 | "id" INTEGER PRIMARY KEY, | |
| 8 | 20 | "name" varchar(100) not null, |
| 9 | 21 | "email" varchar(255) not null, |
| 10 | 22 | "password" varchar(255) not null, |
| @@ -3,8 +3,13 @@ | ||
| 3 | 3 | -- one (stacksjs/stacks#2346: it either keeps the postgres files or writes 80 |
| 4 | 4 | -- migrations for 78 framework models). Fixed upstream, unreleased. Regenerate |
| 5 | 5 | -- and delete this note once it ships. |
| 6 | -- | |
| 7 | -- AUTOINCREMENT dropped, nothing else touched; see the fuller note in | |
| 8 | -- 0000000004-create-users-table.sql. In short: one sqlite-only keyword makes | |
| 9 | -- `buddy migrate` refuse the entire corpus, and bare `INTEGER PRIMARY KEY` | |
| 10 | -- parses in both dialects while still auto-assigning in sqlite. | |
| 6 | 11 | CREATE TABLE IF NOT EXISTS "subscriptions" ( |
| 7 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 12 | "id" INTEGER PRIMARY KEY, | |
| 8 | 13 | "type" text not null, |
| 9 | 14 | "plan" varchar(100), |
| 10 | 15 | "provider_id" varchar(255) not null, |
| @@ -7,8 +7,13 @@ | ||
| 7 | 7 | -- (bounces, complaints, unsubscribes, manual blocks). The mail layer fails |
| 8 | 8 | -- open when the table is missing on sqlite/mysql but not on postgres (its |
| 9 | 9 | -- missing-table matcher doesn't know postgres phrasing), so ship the table. |
| 10 | -- | |
| 11 | -- AUTOINCREMENT dropped, nothing else touched; see the fuller note in | |
| 12 | -- 0000000004-create-users-table.sql. In short: one sqlite-only keyword makes | |
| 13 | -- `buddy migrate` refuse the entire corpus, and bare `INTEGER PRIMARY KEY` | |
| 14 | -- parses in both dialects while still auto-assigning in sqlite. | |
| 10 | 15 | CREATE TABLE IF NOT EXISTS "email_suppressions" ( |
| 11 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 16 | "id" INTEGER PRIMARY KEY, | |
| 12 | 17 | "email" varchar(255) NOT NULL, |
| 13 | 18 | "type" varchar(32) NOT NULL, |
| 14 | 19 | "reason" varchar(255), |
| @@ -12,8 +12,13 @@ | ||
| 12 | 12 | -- skips it and the ALTER below would hard-fail. Mirror the framework schema |
| 13 | 13 | -- (storage/framework/core/database/src/auth-tables.ts) so this migration stands |
| 14 | 14 | -- alone, and is a no-op when the table already exists. |
| 15 | -- | |
| 16 | -- AUTOINCREMENT dropped, nothing else touched; see the fuller note in | |
| 17 | -- 0000000004-create-users-table.sql. In short: one sqlite-only keyword makes | |
| 18 | -- `buddy migrate` refuse the entire corpus, and bare `INTEGER PRIMARY KEY` | |
| 19 | -- parses in both dialects while still auto-assigning in sqlite. | |
| 15 | 20 | CREATE TABLE IF NOT EXISTS "password_resets" ( |
| 16 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 21 | "id" INTEGER PRIMARY KEY, | |
| 17 | 22 | "email" varchar(255) NOT NULL, |
| 18 | 23 | "token" varchar(255) NOT NULL, |
| 19 | 24 | "created_at" timestamp DEFAULT CURRENT_TIMESTAMP, |
| @@ -16,24 +16,49 @@ | ||
| 16 | 16 | -- |
| 17 | 17 | -- See stacksjs/stacks#2346 for the related migration-generator problems. |
| 18 | 18 | -- @generated by `buddy migrate:regenerate` — edits will be overwritten |
| 19 | PRAGMA foreign_keys=OFF; | |
| 20 | BEGIN; | |
| 21 | CREATE TABLE "_qb_tmp_projects" ( | |
| 22 | "id" TEXT PRIMARY KEY, | |
| 23 | "name" TEXT not null, | |
| 24 | "platform" TEXT, | |
| 25 | "dsn" TEXT, | |
| 26 | "ingest_key" TEXT, | |
| 27 | "owner_id" INTEGER, | |
| 28 | "is_active" INTEGER, | |
| 29 | "created_at" TEXT not null default CURRENT_TIMESTAMP, | |
| 30 | "updated_at" TEXT | |
| 31 | ); | |
| 32 | INSERT INTO "_qb_tmp_projects" ("id", "name", "platform", "dsn", "ingest_key", "owner_id", "is_active", "created_at", "updated_at") SELECT "id", "name", "platform", "dsn", "ingest_key", "owner_id", "is_active", "created_at", "updated_at" FROM "projects"; | |
| 33 | DROP TABLE "projects"; | |
| 34 | ALTER TABLE "_qb_tmp_projects" RENAME TO "projects"; | |
| 35 | CREATE UNIQUE INDEX IF NOT EXISTS "projects_ingest_key_unique" ON "projects" ("ingest_key"); | |
| 36 | CREATE UNIQUE INDEX IF NOT EXISTS "projects_projects_ingest_key_unique" ON "projects" ("ingest_key"); | |
| 37 | PRAGMA foreign_key_check; | |
| 38 | COMMIT; | |
| 39 | PRAGMA foreign_keys=ON; | |
| 19 | -- | |
| 20 | -- --------------------------------------------------------------------------- | |
| 21 | -- NEUTRALISED, NOT DELETED. The file and its original SQL are both kept below. | |
| 22 | -- | |
| 23 | -- Everything above was written while this app ran sqlite, and the rebuild was | |
| 24 | -- always cosmetic: it existed only to clear a spurious `--diff`, and the note | |
| 25 | -- opens by saying it does nothing. loghq now runs postgres (DB_CONNECTION in | |
| 26 | -- .env), and this is the one migration of the twenty still unapplied here -- | |
| 27 | -- `migrations` records 80 rows and none of them is this file. | |
| 28 | -- | |
| 29 | -- So it was the only pending statement in the corpus, and on postgres it is | |
| 30 | -- not a no-op at all. `DROP TABLE "projects"` would meet four inbound foreign | |
| 31 | -- keys and be refused, failing the run; the sqlite table-rebuild idiom it uses | |
| 32 | -- (PRAGMA foreign_keys, a _qb_tmp_ copy, DROP, RENAME) has no meaning there. | |
| 33 | -- Its PRAGMA lines were also the last thing making `buddy migrate` reject the | |
| 34 | -- WHOLE corpus as sqlite-flavoured, which left this app unable to apply any | |
| 35 | -- new migration at all. | |
| 36 | -- | |
| 37 | -- Replaced with a statement both dialects accept, so the file can be recorded | |
| 38 | -- as applied and stop blocking the rest. The original is directly below and in | |
| 39 | -- git history; restore it if loghq is ever pointed back at sqlite and the | |
| 40 | -- spurious diff returns. | |
| 41 | -- | |
| 42 | -- PRAGMA foreign_keys=OFF; | |
| 43 | -- BEGIN; | |
| 44 | -- CREATE TABLE "_qb_tmp_projects" ( | |
| 45 | -- "id" TEXT PRIMARY KEY, | |
| 46 | -- "name" TEXT not null, | |
| 47 | -- "platform" TEXT, | |
| 48 | -- "dsn" TEXT, | |
| 49 | -- "ingest_key" TEXT, | |
| 50 | -- "owner_id" INTEGER, | |
| 51 | -- "is_active" INTEGER, | |
| 52 | -- "created_at" TEXT not null default CURRENT_TIMESTAMP, | |
| 53 | -- "updated_at" TEXT | |
| 54 | -- ); | |
| 55 | -- INSERT INTO "_qb_tmp_projects" ("id", "name", "platform", "dsn", "ingest_key", "owner_id", "is_active", "created_at", "updated_at") SELECT "id", "name", "platform", "dsn", "ingest_key", "owner_id", "is_active", "created_at", "updated_at" FROM "projects"; | |
| 56 | -- DROP TABLE "projects"; | |
| 57 | -- ALTER TABLE "_qb_tmp_projects" RENAME TO "projects"; | |
| 58 | -- CREATE UNIQUE INDEX IF NOT EXISTS "projects_ingest_key_unique" ON "projects" ("ingest_key"); | |
| 59 | -- CREATE UNIQUE INDEX IF NOT EXISTS "projects_projects_ingest_key_unique" ON "projects" ("ingest_key"); | |
| 60 | -- PRAGMA foreign_key_check; | |
| 61 | -- COMMIT; | |
| 62 | -- PRAGMA foreign_keys=ON; | |
| 63 | -- --------------------------------------------------------------------------- | |
| 64 | SELECT 1; | |
| @@ -0,0 +1,25 @@ | ||
| 1 | -- SQLITE DIALECT, WRITTEN BY HAND. See stacksjs/stacks#2346. | |
| 2 | -- | |
| 3 | -- A read-scoped project key, so a machine can read this project's logs without | |
| 4 | -- holding a person's credentials (loghqorg/loghq#14). | |
| 5 | -- | |
| 6 | -- `ingest_key` beside it is public and write-only: it ships in client bundles | |
| 7 | -- and only permits writing to one project. This one returns data, so it is a | |
| 8 | -- secret, it is compared in constant time (app/Support/read-key.ts), and the | |
| 9 | -- ingest route never accepts it. | |
| 10 | -- | |
| 11 | -- Both columns are nullable because a key is issued on demand rather than at | |
| 12 | -- creation. A project that never needs one never has one, and revoking is | |
| 13 | -- setting the column back to NULL — which is also why the uniqueness below is | |
| 14 | -- a partial index: every revoked project would otherwise collide on NULL. | |
| 15 | -- | |
| 16 | -- `read_key_last_used_at` exists so somebody can tell whether a key is still | |
| 17 | -- in use before revoking it. It is written at most once a minute per key | |
| 18 | -- (LAST_USED_RESOLUTION_MS), not on every request: the dashboard polls the log | |
| 19 | -- stream, and an UPDATE in front of every poll would cost more than the stamp | |
| 20 | -- is worth. | |
| 21 | -- | |
| 22 | -- Numbered 24, continuing past 0000000023. | |
| 23 | ALTER TABLE "projects" ADD COLUMN "read_key" TEXT; | |
| 24 | ALTER TABLE "projects" ADD COLUMN "read_key_last_used_at" TEXT; | |
| 25 | CREATE UNIQUE INDEX IF NOT EXISTS "projects_read_key_unique" ON "projects" ("read_key") WHERE "read_key" IS NOT NULL; | |
| @@ -0,0 +1,58 @@ | ||
| 1 | -- SQLITE DIALECT, WRITTEN BY HAND. See stacksjs/stacks#2346. | |
| 2 | -- | |
| 3 | -- The two tables behind POST /api/email/subscribe. Without them the newsletter | |
| 4 | -- form on the homepage answered 500 in production: the route is registered | |
| 5 | -- (routes/api.ts) and points at the framework's SubscriberEmailAction, which | |
| 6 | -- opens with `Subscriber.where('email', email).first()` against a table this | |
| 7 | -- app had never created. | |
| 8 | -- | |
| 9 | -- They were missing because `buddy generate:migrations` deliberately does not | |
| 10 | -- emit them. Framework-owned models stopped being generated once app/Models | |
| 11 | -- defines the schema (stacksjs/stacks#2220), and this app does not set | |
| 12 | -- `database.models.includeFrameworkDefaults`. So the generator leaves these 64 | |
| 13 | -- framework tables alone by design, and an app that USES one of their actions | |
| 14 | -- has to bring the table itself. Hand-written rather than generated for that | |
| 15 | -- reason: running the generator here emits Postgres syntax and a duplicate of | |
| 16 | -- the read_key migration beside it. | |
| 17 | -- | |
| 18 | -- Shapes follow storage/framework/defaults/app/Models/{Subscriber,SubscriberEmail}.ts | |
| 19 | -- exactly, because the framework action writes through those models. | |
| 20 | -- | |
| 21 | -- The unique index on email is load-bearing, not decoration. The action's fast | |
| 22 | -- path checks for an existing subscriber and then creates one, so two requests | |
| 23 | -- for the same address can both pass the check; it catches the loser's unique | |
| 24 | -- violation and reports "already subscribed" (stacksjs/stx#1957). With no | |
| 25 | -- index there is no violation to catch and the address is stored twice. | |
| 26 | -- | |
| 27 | -- `uuid` is what the footer's unsubscribe link carries | |
| 28 | -- (/api/email/unsubscribe?token=<uuid>), so it is unique here too. | |
| 29 | ||
| 30 | CREATE TABLE IF NOT EXISTS "subscribers" ( | |
| 31 | "id" INTEGER PRIMARY KEY, | |
| 32 | "uuid" varchar(255), | |
| 33 | "email" varchar(255) not null, | |
| 34 | "status" varchar(255) not null default 'subscribed', | |
| 35 | "source" varchar(100) default 'homepage', | |
| 36 | "unsubscribed_at" timestamp, | |
| 37 | "user_id" bigint REFERENCES "users"("id"), | |
| 38 | "created_at" timestamp not null default CURRENT_TIMESTAMP, | |
| 39 | "updated_at" timestamp | |
| 40 | ); | |
| 41 | ||
| 42 | CREATE UNIQUE INDEX IF NOT EXISTS "subscribers_subscribers_email_unique" ON "subscribers" ("email"); | |
| 43 | CREATE UNIQUE INDEX IF NOT EXISTS "subscribers_subscribers_uuid_unique" ON "subscribers" ("uuid"); | |
| 44 | ||
| 45 | -- One row per signup event, kept even after the subscriber unsubscribes, which | |
| 46 | -- is why it is a separate table rather than a column on `subscribers`. | |
| 47 | CREATE TABLE IF NOT EXISTS "subscriber_emails" ( | |
| 48 | "id" INTEGER PRIMARY KEY, | |
| 49 | "uuid" varchar(255), | |
| 50 | "email" varchar(255) not null, | |
| 51 | "source" varchar(100) default 'homepage', | |
| 52 | "subscriber_id" bigint REFERENCES "subscribers"("id"), | |
| 53 | "created_at" timestamp not null default CURRENT_TIMESTAMP, | |
| 54 | "updated_at" timestamp | |
| 55 | ); | |
| 56 | ||
| 57 | CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_emails_subscriber_emails_uuid_unique" ON "subscriber_emails" ("uuid"); | |
| 58 | CREATE INDEX IF NOT EXISTS "subscriber_emails_email" ON "subscriber_emails" ("email"); | |
| @@ -0,0 +1,219 @@ | ||
| 1 | /** | |
| 2 | * The account you sign in with locally, and the production owner bootstrap. | |
| 3 | * | |
| 4 | * ./buddy seed --only-seeders OwnerSeeder --skip-models | |
| 5 | * | |
| 6 | * ## Why this file has to exist | |
| 7 | * | |
| 8 | * Model-factory seeding will not produce a usable sign-in, in two separate | |
| 9 | * ways. `User` is on @stacksjs/database's ACCOUNT_MODELS list, so the factory | |
| 10 | * pass leaves foreign keys that point at a user null rather than inventing an | |
| 11 | * owner; the framework's note on that list says which account owns a seeded | |
| 12 | * row is "a decision for the app that seeded it (a `demo:account` command, a | |
| 13 | * fixture, a migration), not something a factory should guess". And the | |
| 14 | * `useSeeder` trait on `User` writes rows whose emails come from | |
| 15 | * `faker.internet.email()` -- accounts nobody can sign in to, because the | |
| 16 | * addresses are unknowable. This file is that decision for LogHQ. | |
| 17 | * | |
| 18 | * ## What this app needs that the others do not | |
| 19 | * | |
| 20 | * LogHQ has no teams. A `teams` table exists from the framework's own | |
| 21 | * migrations, but it is empty and nothing reads it: there is no Team model, | |
| 22 | * no `team_members` table to join through, and app/Actions/Auth/ | |
| 23 | * RegisterAction.ts says so in as many words -- "no teams model, so there is | |
| 24 | * no personal-team creation here". Tenancy is a `project` with an `owner_id`. | |
| 25 | * So the seeder makes a user and one project owned by it, which is what the | |
| 26 | * dashboard needs to render something other than an empty state. | |
| 27 | * | |
| 28 | * `project_members` is deliberately NOT written. It keys members by `email` | |
| 29 | * rather than `user_id` and exists for invitations; an owner is established | |
| 30 | * by `projects.owner_id`, exactly as routes/projects.ts does it when a | |
| 31 | * project is created through the UI. | |
| 32 | * | |
| 33 | * `newProjectId` and `newIngestKey` are imported from app/Support/projects | |
| 34 | * rather than reimplemented, so a seeded project is indistinguishable from | |
| 35 | * one created through the app -- same id shape, same key shape. A key | |
| 36 | * invented here in a different format would be accepted by the seeder and | |
| 37 | * then rejected by ingest, which is the sort of difference that only shows | |
| 38 | * up once an SDK is pointed at it. | |
| 39 | * | |
| 40 | * ## Credentials | |
| 41 | * | |
| 42 | * `LOGHQ_OWNER_EMAIL` / `LOGHQ_OWNER_PASSWORD` when set. A local environment | |
| 43 | * falls back to the constants below so that a fresh clone can sign in after | |
| 44 | * one `./buddy seed` with nothing to configure; every other environment must | |
| 45 | * supply the password, and production refuses to run without both. The | |
| 46 | * fallback is keyed on an explicit allowlist of APP_ENV values rather than | |
| 47 | * "not production", so a staging deploy that forgets to set them gets the | |
| 48 | * skip and not a published default password. | |
| 49 | */ | |
| 50 | import { db, Seeder } from '@stacksjs/database' | |
| 51 | import { env } from '@stacksjs/env' | |
| 52 | import { log } from '@stacksjs/logging' | |
| 53 | import { makeHash, verifyHash } from '@stacksjs/security' | |
| 54 | import { newIngestKey, newProjectId } from '../../app/Support/projects' | |
| 55 | ||
| 56 | /** | |
| 57 | * The local fallback. `.localhost` is reserved by RFC 6761 and resolves | |
| 58 | * nowhere but this machine, so the address cannot be mistaken for a real | |
| 59 | * mailbox or receive a stray notification. | |
| 60 | */ | |
| 61 | const DEV_EMAIL = 'owner@loghq.localhost' | |
| 62 | const DEV_PASSWORD = 'stacks-dev-only' | |
| 63 | const DEV_NAME = 'LogHQ Owner' | |
| 64 | const PROJECT_NAME = 'LogHQ' | |
| 65 | ||
| 66 | /** | |
| 67 | * APP_ENV values that may use the fallback credentials above. | |
| 68 | * | |
| 69 | * `dev` is in here deliberately: config/env.ts types APP_ENV as the enum | |
| 70 | * local|dev|stage|prod, so `dev` is a value this app really takes even though | |
| 71 | * the committed .env says `development`. Without it the seeder would silently | |
| 72 | * skip on a correctly-configured machine and report success while writing | |
| 73 | * nothing. `stage` is deliberately NOT here -- it falls through to the | |
| 74 | * "supply the password" branch, which is the point of listing the local | |
| 75 | * values explicitly instead of testing for "not production". | |
| 76 | */ | |
| 77 | const LOCAL_ENVS = new Set(['development', 'dev', 'local', 'test', 'testing']) | |
| 78 | const PRODUCTION_ENVS = new Set(['prod', 'production']) | |
| 79 | ||
| 80 | /** | |
| 81 | * The id of the one user with this address, or undefined. | |
| 82 | * | |
| 83 | * Number(): a Postgres bigint id arrives as a string, and a string flowing | |
| 84 | * into `projects.owner_id` is a foreign key the dialect then rejects. Note | |
| 85 | * this is only safe for `users` -- `projects.id` is TEXT in this app, so it | |
| 86 | * is looked up separately below rather than through here. | |
| 87 | */ | |
| 88 | async function findUserId(email: string): Promise<number | undefined> { | |
| 89 | const row = await db.selectFrom('users' as any) | |
| 90 | .select(['id'] as any) | |
| 91 | .where('email' as any, '=', email) | |
| 92 | .executeTakeFirst() as { id?: number | string } | undefined | |
| 93 | ||
| 94 | return row?.id === undefined ? undefined : Number(row.id) | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Whether `password` is the one behind `stored`. | |
| 99 | * | |
| 100 | * verifyHash THROWS rather than returning false when `stored` is not a hash | |
| 101 | * it can parse -- `Password verification failed with error "InvalidEncoding"` | |
| 102 | * -- and a row whose password column holds something unparseable is precisely | |
| 103 | * the case the caller is trying to repair. | |
| 104 | */ | |
| 105 | async function passwordMatches(password: string, stored: unknown): Promise<boolean> { | |
| 106 | if (typeof stored !== 'string' || stored.length === 0) | |
| 107 | return false | |
| 108 | ||
| 109 | try { | |
| 110 | return await verifyHash(password, stored) | |
| 111 | } | |
| 112 | catch { | |
| 113 | return false | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | export default class OwnerSeeder extends Seeder { | |
| 118 | static override tags = ['owner'] as const | |
| 119 | ||
| 120 | async run(): Promise<void> { | |
| 121 | const appEnv = String(env.APP_ENV || '').toLowerCase() | |
| 122 | const isLocal = LOCAL_ENVS.has(appEnv) | |
| 123 | const isProduction = PRODUCTION_ENVS.has(appEnv) | |
| 124 | ||
| 125 | const envEmail = String(env.LOGHQ_OWNER_EMAIL || '').trim().toLowerCase() | |
| 126 | const envPassword = String(env.LOGHQ_OWNER_PASSWORD || '') | |
| 127 | ||
| 128 | if (isProduction && (!envEmail || !envPassword)) | |
| 129 | throw new Error('LOGHQ_OWNER_EMAIL and LOGHQ_OWNER_PASSWORD are both required for the production owner bootstrap.') | |
| 130 | ||
| 131 | if (!envPassword && !isLocal) { | |
| 132 | log.info(`[OwnerSeeder] Skipped: LOGHQ_OWNER_PASSWORD is not set and APP_ENV="${appEnv}" is not a local environment.`) | |
| 133 | return | |
| 134 | } | |
| 135 | ||
| 136 | const email = envEmail || DEV_EMAIL | |
| 137 | const password = envPassword || DEV_PASSWORD | |
| 138 | const usedFallback = !envPassword | |
| 139 | const now = new Date().toISOString() | |
| 140 | ||
| 141 | let userId = await findUserId(email) | |
| 142 | if (!userId) { | |
| 143 | await db.insertInto('users' as any).values({ | |
| 144 | name: DEV_NAME, | |
| 145 | email, | |
| 146 | password: await makeHash(password, { algorithm: 'bcrypt' }), | |
| 147 | uuid: crypto.randomUUID(), | |
| 148 | // NOT NULL on this app's users table. This schema has no | |
| 149 | // is_super_admin and no avatar column. | |
| 150 | two_factor_enabled: false, | |
| 151 | // Pre-verified: the local mailbox does not exist, so a seeded account | |
| 152 | // that had to click a link in an email could never finish signing in. | |
| 153 | email_verified_at: now, | |
| 154 | created_at: now, | |
| 155 | updated_at: now, | |
| 156 | } as any).execute() | |
| 157 | userId = await findUserId(email) | |
| 158 | } | |
| 159 | else if (isLocal) { | |
| 160 | /* | |
| 161 | * Keep the documented local password working. Re-running the seeder | |
| 162 | * after changing DEV_PASSWORD, or after an earlier experiment wrote | |
| 163 | * this row with a different hash, would otherwise leave a row that | |
| 164 | * exists and cannot be signed in to -- the exact failure this file is | |
| 165 | * here to end. Checked before writing so a run that changes nothing | |
| 166 | * issues no UPDATE, and confined to local environments so a deploy | |
| 167 | * never silently rotates a real account's password. | |
| 168 | */ | |
| 169 | const row = await db.selectFrom('users' as any) | |
| 170 | .select(['password'] as any) | |
| 171 | .where('email' as any, '=', email) | |
| 172 | .executeTakeFirst() as { password?: string } | undefined | |
| 173 | ||
| 174 | if (!await passwordMatches(password, row?.password)) { | |
| 175 | await db.updateTable('users' as any) | |
| 176 | .set({ password: await makeHash(password, { algorithm: 'bcrypt' }), updated_at: now } as any) | |
| 177 | .where('email' as any, '=', email) | |
| 178 | .execute() | |
| 179 | log.info('[OwnerSeeder] Reset the local owner password to match this seeder.') | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | if (!userId) | |
| 184 | throw new Error('The LogHQ owner account could not be resolved after creation.') | |
| 185 | ||
| 186 | /* | |
| 187 | * One project per owner per name, which is the rule routes/projects.ts | |
| 188 | * enforces on create (it answers 409 on a clash). Matched on owner_id as | |
| 189 | * well as name so the seeder cannot adopt another account's project of | |
| 190 | * the same name. | |
| 191 | */ | |
| 192 | const project = await db.selectFrom('projects' as any) | |
| 193 | .select(['id'] as any) | |
| 194 | .where('owner_id' as any, '=', userId) | |
| 195 | .where('name' as any, '=', PROJECT_NAME) | |
| 196 | .executeTakeFirst() as { id?: string } | undefined | |
| 197 | ||
| 198 | if (!project) { | |
| 199 | await db.insertInto('projects' as any).values({ | |
| 200 | // TEXT, not an autoincrement integer: this app mints its own. | |
| 201 | id: newProjectId(PROJECT_NAME), | |
| 202 | name: PROJECT_NAME, | |
| 203 | platform: 'javascript', | |
| 204 | ingest_key: newIngestKey(), | |
| 205 | owner_id: userId, | |
| 206 | is_active: true, | |
| 207 | created_at: now, | |
| 208 | updated_at: now, | |
| 209 | } as any).execute() | |
| 210 | } | |
| 211 | ||
| 212 | log.info(`[OwnerSeeder] Owner project is ready for ${email}.`) | |
| 213 | if (usedFallback) | |
| 214 | log.info(`[OwnerSeeder] Local fallback credentials in use: ${email} / ${DEV_PASSWORD}`) | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | if (import.meta.main) | |
| 219 | await new OwnerSeeder().run() | |
| @@ -87,9 +87,9 @@ | ||
| 87 | 87 | }, |
| 88 | 88 | "//overrides": "stx is not a direct dependency \u2014 the only reference is a type import in config/ui.ts. But it arrives through nine transitive paths, and bun-plugin-stx asks for a range so loose (^0.2.72) that dropping the old direct pin let the hoisted copy fall to 0.2.176 while the framework packages sat on 0.2.231. bun-plugin-stx resolves to the HOISTED copy, so that is the version that renders every .stx file. Every constraint in the tree (0.2.378, ^0.2.378, ^0.2.375, >=0.2.227, ^0.2.82, as of stacks 0.75.79) is satisfied by 0.2.378, so pinning it here collapses the tree to one copy without re-declaring a dependency this app does not have. Re-check after a stacks bump.", |
| 89 | 89 | "overrides": { |
| 90 | "@stacksjs/components": "0.2.401", | |
| 91 | "bun-plugin-stx": "0.2.401", | |
| 92 | "@stacksjs/stx": "0.2.401" | |
| 90 | "@stacksjs/components": "0.2.532", | |
| 91 | "bun-plugin-stx": "0.2.532", | |
| 92 | "@stacksjs/stx": "0.2.532" | |
| 93 | 93 | }, |
| 94 | 94 | "devDependencies": { |
| 95 | 95 | "@iconify-json/lucide": "^1.2.121", |
public/app-chrome.cssdeleted+0-370
| @@ -1,370 +0,0 @@ | ||
| 1 | /* App-surface CSS that utilities provably cannot express. | |
| 2 | * | |
| 3 | * Everything here was measured against Crosswind's generated sheet before it | |
| 4 | * was moved: pseudo-elements, attribute-state selectors, bare element | |
| 5 | * selectors, `@keyframes`, and page-scoped custom properties all produce NO | |
| 6 | * rule when written as a utility or variant. The failure is silent — no error, | |
| 7 | * no warning, just a class that does nothing — so the test is always "does the | |
| 8 | * served sheet contain it", never "does it look plausible". | |
| 9 | * | |
| 10 | * `config/crosswind.ts` `preflights` is where ch.11.2/11.7 says this belongs. | |
| 11 | * It does not work on the stx serve path: the entry is spread into the | |
| 12 | * generator config and then never emitted, verified with a probe token that | |
| 13 | * appears nowhere in the served CSS on any route. A linked stylesheet is the | |
| 14 | * substitute, and it is also the router-safe home — the SPA router destroys | |
| 15 | * every <head> style that is not the Crosswind tag, but leaves <link>s alone. | |
| 16 | * | |
| 17 | * Linked once from config/ui.ts `app.head.link`, after /tokens.css so it can | |
| 18 | * read the palette, before the Crosswind sheet so utilities still win a | |
| 19 | * specificity tie against anything here. | |
| 20 | * | |
| 21 | * Rules that WERE expressible are deliberately absent — they live as utilities | |
| 22 | * in the markup. Do not move a rule back here because it is more convenient. | |
| 23 | */ | |
| 24 | ||
| 25 | /* --------------------------------------------------------------------------- | |
| 26 | * Page-scoped tokens | |
| 27 | * | |
| 28 | * dashboard is the one surface with its own accent: indigo, so navigation | |
| 29 | * never reads as a severity colour (see 20f40ed). It used to be a `:root` | |
| 30 | * block inside dashboard.stx's <style>, scoped only by the accident of which | |
| 31 | * page carried the tag. Declared globally that would repaint the whole app, so | |
| 32 | * it hangs off a wrapper class on the dashboard's root element and cascades to | |
| 33 | * its descendants — including the fixed-position chrome, which is inside the | |
| 34 | * wrapper. | |
| 35 | * ------------------------------------------------------------------------ */ | |
| 36 | .page-dashboard { | |
| 37 | --accent: #4f46e5; | |
| 38 | --rail: rgba(79, 70, 229, 0.14); | |
| 39 | } | |
| 40 | ||
| 41 | @media (prefers-color-scheme: dark) { | |
| 42 | .page-dashboard { | |
| 43 | --accent: #818cf8; | |
| 44 | --rail: rgba(129, 140, 248, 0.16); | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | :root[data-theme='dark'] .page-dashboard { | |
| 49 | --accent: #818cf8; | |
| 50 | --rail: rgba(129, 140, 248, 0.16); | |
| 51 | } | |
| 52 | ||
| 53 | :root[data-theme='light'] .page-dashboard { | |
| 54 | --accent: #4f46e5; | |
| 55 | --rail: rgba(79, 70, 229, 0.14); | |
| 56 | } | |
| 57 | ||
| 58 | /* Channel brand colours. Only settings uses them, but they are declared here | |
| 59 | * with everything else rather than in a page tag. */ | |
| 60 | :root { | |
| 61 | --slack: #611f69; | |
| 62 | --discord: #5865f2; | |
| 63 | } | |
| 64 | ||
| 65 | @media (prefers-color-scheme: dark) { | |
| 66 | :root { | |
| 67 | --discord: #818cf8; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | :root[data-theme='dark'] { | |
| 72 | --discord: #818cf8; | |
| 73 | } | |
| 74 | ||
| 75 | :root[data-theme='light'] { | |
| 76 | --discord: #5865f2; | |
| 77 | } | |
| 78 | ||
| 79 | /* Tabular numerals for the log stream's counts and timestamps, so digits do | |
| 80 | * not jitter as values change. | |
| 81 | * | |
| 82 | * This is a real rule rather than an arbitrary property because the value has | |
| 83 | * to be quoted — `font-feature-settings: 'tnum' 1` — and a quoted value cannot | |
| 84 | * survive Crosswind's class-name syntax. Written as | |
| 85 | * [font-feature-settings:'tnum'_1] it generates NOTHING; the three | |
| 86 | * font-feature-settings declarations in the served sheet are all preflight | |
| 87 | * resets, and the computed value on the element stayed `normal`. | |
| 88 | * | |
| 89 | * `tabular-nums` is not a substitute: it sets font-variant-numeric, a | |
| 90 | * different property, and would not reproduce the measured baseline. | |
| 91 | */ | |
| 92 | .tnum { | |
| 93 | font-feature-settings: 'tnum' 1; | |
| 94 | } | |
| 95 | ||
| 96 | /* --------------------------------------------------------------------------- | |
| 97 | * Bare element selectors — no utility targets an element type | |
| 98 | * ------------------------------------------------------------------------ */ | |
| 99 | html { | |
| 100 | scroll-behavior: smooth; | |
| 101 | } | |
| 102 | ||
| 103 | /* --------------------------------------------------------------------------- | |
| 104 | * settings: the alert-channel toggle | |
| 105 | * | |
| 106 | * The knob is a ::after pseudo-element and both states are attribute-driven. | |
| 107 | * Crosswind generates nothing for `after:` or for a `[data-on]` variant. | |
| 108 | * ------------------------------------------------------------------------ */ | |
| 109 | .tgl::after { | |
| 110 | content: ''; | |
| 111 | position: absolute; | |
| 112 | top: 2px; | |
| 113 | left: 2px; | |
| 114 | width: 18px; | |
| 115 | height: 18px; | |
| 116 | border-radius: 999px; | |
| 117 | background: #fff; | |
| 118 | transition: transform 0.15s ease; | |
| 119 | } | |
| 120 | ||
| 121 | .tgl[data-on='1'] { | |
| 122 | background: var(--ok); | |
| 123 | } | |
| 124 | ||
| 125 | .tgl[data-on='1']::after { | |
| 126 | transform: translateX(16px); | |
| 127 | } | |
| 128 | ||
| 129 | /* settings: the Slack/Discord channel picker. Selected state is attribute | |
| 130 | * driven, and each brand paints its own fill. */ | |
| 131 | .seg[data-sel='1'] { | |
| 132 | color: var(--accent-ink); | |
| 133 | border-color: transparent; | |
| 134 | } | |
| 135 | ||
| 136 | .seg-slack[data-sel='1'] { | |
| 137 | background: var(--slack); | |
| 138 | } | |
| 139 | ||
| 140 | .seg-discord[data-sel='1'] { | |
| 141 | background: var(--discord); | |
| 142 | } | |
| 143 | ||
| 144 | /* settings: delete-zone modal. `[hidden]` has to beat the overlay's own | |
| 145 | * `display: flex`, which a utility cannot guarantee — Crosswind's preflight | |
| 146 | * `[hidden]{display:none}` ties with `.flex` on specificity and loses on | |
| 147 | * order. */ | |
| 148 | .dz-overlay[hidden] { | |
| 149 | display: none; | |
| 150 | } | |
| 151 | ||
| 152 | @keyframes dz-fade { | |
| 153 | from { | |
| 154 | opacity: 0; | |
| 155 | } | |
| 156 | ||
| 157 | to { | |
| 158 | opacity: 1; | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | @keyframes dz-pop { | |
| 163 | from { | |
| 164 | opacity: 0; | |
| 165 | transform: translateY(6px) scale(0.98); | |
| 166 | } | |
| 167 | ||
| 168 | to { | |
| 169 | opacity: 1; | |
| 170 | transform: none; | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /* --------------------------------------------------------------------------- | |
| 175 | * dashboard | |
| 176 | * | |
| 177 | * Moved verbatim from dashboard.stx's <style> block. Every family below is | |
| 178 | * entangled with a selector no utility reaches, so splitting them would have | |
| 179 | * meant half a component in each place: | |
| 180 | * | |
| 181 | * .pill* hover/active are tag-scoped (`a.pill:hover, button.pill:hover`) | |
| 182 | * on purpose — the disabled pager is a <span class="pill"> and | |
| 183 | * must NOT light up on hover. | |
| 184 | * .log-row `details[open] > .log-row` has to outrank the hover state. | |
| 185 | * .search-input ::placeholder and :focus-visible. | |
| 186 | * .chrome-* `.chrome-pro, .chrome-pro:hover` cancels .chrome-upgrade's | |
| 187 | * hover: Pro is a status, not an action. | |
| 188 | * summary bare element selector plus ::-webkit-details-marker. | |
| 189 | * :focus-visible a bare-element accessibility rule, not optional. | |
| 190 | * .grain, data-URI backgrounds with prefers-color-scheme variants. | |
| 191 | * select.app-panel | |
| 192 | * .reveal @keyframes plus a reduced-motion variant. | |
| 193 | * | |
| 194 | * What IS expressible went the other way and is now utilities in the markup: | |
| 195 | * .mono, .wordmark, .wordmark-link, .panel, .lvl-chip, .ctx-pre and .icon-btn. | |
| 196 | * ------------------------------------------------------------------------ */ | |
| 197 | .pill { border-radius: 999px; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; } | |
| 198 | a.pill:hover, button.pill:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 6%, transparent); } | |
| 199 | a.pill:active, button.pill:active { transform: translateY(1px); } | |
| 200 | /* Filter-tab state. These were inline style ternaries on every tab, which | |
| 201 | outrank the hover rules above and could not be themed. The two levels differ | |
| 202 | because the severity tabs are the primary control and the range tabs are | |
| 203 | secondary. */ | |
| 204 | .pill-on { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 205 | a.pill-on:hover { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 206 | .pill-off { color: var(--text-2); border-color: var(--border); } | |
| 207 | .pill-on-soft { color: var(--accent); border-color: var(--accent); } | |
| 208 | .pill-off-soft { color: var(--text-3); border-color: var(--border); } | |
| 209 | .log-row { transition: background-color 0.12s ease; } | |
| 210 | .log-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); } | |
| 211 | details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); } | |
| 212 | summary { list-style: none; cursor: pointer; } | |
| 213 | summary::-webkit-details-marker { display: none; } | |
| 214 | .search-input { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; color: var(--text); outline: none; transition: border-color 0.15s ease; } | |
| 215 | .search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } | |
| 216 | .search-input::placeholder { color: var(--text-3); } | |
| 217 | /* Position was an inline style string assembled in JS. It lives here now, so | |
| 218 | the chrome is styleable and themeable like everything else. */ | |
| 219 | .chrome-logout, .chrome-upgrade { position: fixed; top: 16px; z-index: 60; padding: 8px 13px; border-radius: 9px; font-size: 13px; line-height: 1; font-family: var(--sans, ui-sans-serif); } | |
| 220 | .chrome-logout { right: 18px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); transition: color 0.15s ease, border-color 0.15s ease; } | |
| 221 | .chrome-logout:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } | |
| 222 | .chrome-upgrade { right: 112px; font-weight: 600; text-decoration: none; background: var(--accent); color: var(--accent-ink); transition: filter 0.15s ease, transform 0.12s ease; } | |
| 223 | .chrome-upgrade:hover { filter: brightness(1.12); } | |
| 224 | .chrome-upgrade:active { transform: translateY(1px); } | |
| 225 | /* Pro is a status, not an action: no link, no hover lift. */ | |
| 226 | .chrome-pro, .chrome-pro:hover { background: transparent; color: var(--accent); border: 1px solid var(--accent); cursor: default; filter: none; } | |
| 227 | /* The ONLY .reveal in the app. marketing.css carried a second copy with | |
| 228 | different values (0.6s/12px against this one's 0.5s/10px) and a second | |
| 229 | @keyframes of the same name; both sheets load on every marketing page, so the | |
| 230 | marketing copy won there and this one only ever reached the dashboard. These | |
| 231 | are the marketing values, kept because 23 pages used them and one did not. */ | |
| 232 | .reveal { opacity: 0; transform: translateY(12px); animation: reveal 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| 233 | @keyframes reveal { to { opacity: 1; transform: none; } } | |
| 234 | @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } } | |
| 235 | /* Keyboard focus — a visible ring on every interactive control (accessibility, | |
| 236 | not optional). Search gets an inset ring so it doesn't clash with its pill. | |
| 237 | ||
| 238 | This rule used to also declare `border-radius: 8px`, which was not a property | |
| 239 | of the ring — it RESHAPED THE ELEMENT for as long as it held focus. Tabbing | |
| 240 | onto a filter tab squared off its 999px pill; the same happened to the pager | |
| 241 | and to every rounded control here, because `a:focus-visible` (0,1,1) outranks | |
| 242 | `.pill` (0,1,0). Nothing about a keyboard user's focus should change an | |
| 243 | element's geometry. | |
| 244 | ||
| 245 | Nothing replaces it: `outline` has followed the element's own border-radius | |
| 246 | since Chrome 94 / Firefox 88 / Safari 16.4, so the ring is correctly rounded | |
| 247 | on a pill and correctly square on a square control, which is what the 8px was | |
| 248 | reaching for in the first place. */ | |
| 249 | a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible { | |
| 250 | outline: 2px solid var(--accent); outline-offset: 2px; | |
| 251 | } | |
| 252 | summary:focus-visible { outline-offset: -2px; } | |
| 253 | .search-input:focus-visible { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } | |
| 254 | /* Physical press feedback on a log row. */ | |
| 255 | .log-row:active { background: color-mix(in srgb, var(--accent) 9%, transparent); } | |
| 256 | /* Log stream aligned as fixed columns: rail · time · level · channel · message. */ | |
| 257 | .log-row { display: grid; grid-template-columns: 3px 4.75rem 3.75rem 6rem minmax(0, 1fr); align-items: center; column-gap: 0.75rem; } | |
| 258 | /* Subtle grain so the flat surfaces don't read as sterile. Fixed, non-blocking, | |
| 259 | barely-there; sits under the floating chrome (z-60). */ | |
| 260 | .grain { position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0.03; | |
| 261 | background-size: 170px 170px; | |
| 262 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } | |
| 263 | @media (prefers-reduced-motion: no-preference) { .grain { opacity: 0.032; } } | |
| 264 | /* Project dropdown: the native select chevron hugged the rounded border. Reset | |
| 265 | the native appearance and draw our own with a comfortable inset + room for the | |
| 266 | value text. Chevron is themed to --text-3 in each mode. */ | |
| 267 | select.app-panel { | |
| 268 | -webkit-appearance: none; -moz-appearance: none; appearance: none; | |
| 269 | padding-right: 2rem; background-repeat: no-repeat; | |
| 270 | background-position: right 0.7rem center; background-size: 13px; | |
| 271 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2397a1b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); | |
| 272 | } | |
| 273 | @media (prefers-color-scheme: dark) { select.app-panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); } } | |
| 274 | ||
| 275 | /* Log severity, driven by one data attribute rather than an inline style. | |
| 276 | * | |
| 277 | * The rail and the level badge on a log row both used | |
| 278 | * `style="background: "`, and the stat dot and | |
| 279 | * value used `style="color: "` — four inline declarations carrying | |
| 280 | * hex literals computed in the view. `data-level` on the row sets `--sev` once | |
| 281 | * and both children read it, so the colour is named in exactly one place | |
| 282 | * (public/tokens.css) and follows the theme. | |
| 283 | * | |
| 284 | * The default covers a level the server does not recognise: it falls back to | |
| 285 | * the debug tone rather than to no colour, so the rail never disappears. */ | |
| 286 | [data-level] { --sev: var(--sev-debug); } | |
| 287 | [data-level='emergency'], [data-level='alert'], [data-level='critical'] { --sev: var(--sev-critical); } | |
| 288 | [data-level='error'] { --sev: var(--sev-error); } | |
| 289 | [data-level='warning'] { --sev: var(--sev-warning); } | |
| 290 | [data-level='notice'] { --sev: var(--sev-notice); } | |
| 291 | [data-level='info'] { --sev: var(--sev-info); } | |
| 292 | .log-rail { background: var(--sev); min-height: 20px; } | |
| 293 | .log-level { color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); } | |
| 294 | /* One segment of the level-mix bars on /analytics. Reads the same --sev the | |
| 295 | * rail and the chip do, so a colour still means the same severity across the | |
| 296 | * whole app and the analytics page names no palette of its own. */ | |
| 297 | .level-seg { background: var(--sev); } | |
| 298 | ||
| 299 | /* Stat cards. `data-tone` is set only when the count is non-zero, so an empty | |
| 300 | * warnings/errors card stays neutral — which is what `s.on` expressed inline. */ | |
| 301 | .stat-dot { background: var(--text-3); opacity: 0.35; } | |
| 302 | .stat-value { color: var(--text); } | |
| 303 | [data-tone='warning'] .stat-dot { background: var(--sev-warning); opacity: 1; } | |
| 304 | [data-tone='warning'] .stat-value { color: var(--sev-warning); } | |
| 305 | [data-tone='error'] .stat-dot { background: var(--sev-critical); opacity: 1; } | |
| 306 | [data-tone='error'] .stat-value { color: var(--sev-critical); } | |
| 307 | ||
| 308 | /* --------------------------------------------------------------------------- | |
| 309 | * Narrow viewports | |
| 310 | * | |
| 311 | * This sheet had NO width query at all, while marketing.css has four. The app | |
| 312 | * surface is the half that people actually work in, and it was the half with no | |
| 313 | * mobile behaviour — the two rules below are both fixed geometry that a phone | |
| 314 | * cannot satisfy. | |
| 315 | * | |
| 316 | * Everything here adjusts rules THIS FILE owns. It deliberately does not try to | |
| 317 | * override a utility (the page's own `px-5 py-10`, for instance): this sheet is | |
| 318 | * linked before the Crosswind sheet precisely so utilities win a specificity | |
| 319 | * tie, so a `.page-dashboard { padding-top }` here would be silently dead. Any | |
| 320 | * responsive change to a utility-styled element belongs in the markup as an | |
| 321 | * `sm:`/`md:` variant. | |
| 322 | * | |
| 323 | * 40rem rather than a round 375px: the breakpoint is where the content stops | |
| 324 | * fitting, and it is in rem so it also fires for someone at a large default | |
| 325 | * font size on a wider screen, who has the same problem for a different reason. | |
| 326 | * ------------------------------------------------------------------------ */ | |
| 327 | @media (max-width: 40rem) { | |
| 328 | /* The log stream was a single row of five fixed columns: | |
| 329 | * 3px rail + 4.75rem time + 3.75rem level + 6rem channel | |
| 330 | * plus four 0.75rem gaps and the row's own 20px side padding — 323px spoken | |
| 331 | * for before the message column starts. On a 375px phone that leaves the | |
| 332 | * message 52px, about four characters, on the one line that carries what | |
| 333 | * actually happened. The row was not narrow, it was empty. | |
| 334 | * | |
| 335 | * It becomes two rows: metadata above, message across the full width below. | |
| 336 | * Nothing is dropped — the channel is not repeated in the expanded panel, so | |
| 337 | * hiding it would have lost the only place it appears. | |
| 338 | * | |
| 339 | * Addressed by position because these are bare spans in the markup with no | |
| 340 | * hooks of their own, and the order is fixed by the template: rail, time, | |
| 341 | * level, channel, message. */ | |
| 342 | .log-row { | |
| 343 | grid-template-columns: 3px auto auto minmax(0, 1fr); | |
| 344 | column-gap: 0.5rem; | |
| 345 | row-gap: 0.2rem; | |
| 346 | } | |
| 347 | ||
| 348 | /* The rail spans both rows so it stays one continuous severity stripe beside | |
| 349 | * the whole entry rather than a stub next to the timestamp. */ | |
| 350 | .log-row > :nth-child(1) { grid-row: 1 / span 2; } | |
| 351 | .log-row > :nth-child(5) { grid-column: 2 / -1; } | |
| 352 | ||
| 353 | /* The floating chrome is positioned for a desktop margin: `top: 16px` with | |
| 354 | * 8px padding puts its lower edge at about 46px, and the page's own content | |
| 355 | * starts at 40px. On a wide screen the centred column leaves that gutter | |
| 356 | * empty so nothing shows; on a phone the column is the full width and the | |
| 357 | * header runs underneath the buttons. | |
| 358 | * | |
| 359 | * Smaller and tighter, so the chrome's lower edge lands near 32px and closes | |
| 360 | * the overlap window rather than merely making it less likely. */ | |
| 361 | .chrome-logout, .chrome-upgrade { | |
| 362 | top: 10px; | |
| 363 | padding: 5px 10px; | |
| 364 | font-size: 12px; | |
| 365 | border-radius: 8px; | |
| 366 | } | |
| 367 | ||
| 368 | .chrome-logout { right: 12px; } | |
| 369 | .chrome-upgrade { right: 88px; } | |
| 370 | } | |
public/marketing.cssdeleted+0-378
| @@ -1,378 +0,0 @@ | ||
| 1 | /* loghq marketing surface — shared design tokens + components. | |
| 2 | Every marketing page (landing, feature pages, use-cases) links this so the | |
| 3 | whole surface stays visually identical. Light + dark, Space Grotesk, emerald accent. */ | |
| 4 | ||
| 5 | :root { | |
| 6 | --bg: #fbfbfd; | |
| 7 | --surface: #ffffff; | |
| 8 | --surface-2: #f4f5f8; | |
| 9 | --border: rgba(15, 23, 42, 0.09); | |
| 10 | --border-strong: rgba(15, 23, 42, 0.16); | |
| 11 | --text: #0b0f19; | |
| 12 | --text-2: #4b5565; | |
| 13 | --text-3: #6b7280; | |
| 14 | --accent: #047857; | |
| 15 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 16 | --code-bg: #0b0f19; | |
| 17 | --code-ink: #d6deeb; | |
| 18 | /* Caption ink for the code surface. Not --text-3: the code block is dark | |
| 19 | * in BOTH themes, so the light --text-3 (#6b7280) lands on it at 3.96:1 — | |
| 20 | * the one WCAG AA failure left after the token pass. This clears 6.37:1 on | |
| 21 | * the light --code-bg and 6.70:1 on the dark one, so it needs no variant. */ | |
| 22 | --code-cap: #8b95ab; | |
| 23 | --r: 14px; | |
| 24 | --r-sm: 9px; | |
| 25 | --sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| 26 | --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 27 | } | |
| 28 | ||
| 29 | @media (prefers-color-scheme: dark) { | |
| 30 | :root { | |
| 31 | --bg: #090b11; --surface: #10131c; --surface-2: #161a24; | |
| 32 | --border: rgba(148, 163, 184, 0.12); --border-strong: rgba(148, 163, 184, 0.2); | |
| 33 | --text: #f3f5f9; --text-2: #b3bccb; --text-3: #79839a; | |
| 34 | --accent: #34d399; | |
| 35 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 36 | --code-bg: #05070c; | |
| 37 | } | |
| 38 | } | |
| 39 | :root[data-theme="dark"] { | |
| 40 | --bg: #090b11; --surface: #10131c; --surface-2: #161a24; | |
| 41 | --border: rgba(148, 163, 184, 0.12); --border-strong: rgba(148, 163, 184, 0.2); | |
| 42 | --text: #f3f5f9; --text-2: #b3bccb; --text-3: #79839a; | |
| 43 | --accent: #34d399; | |
| 44 | --accent-line: rgba(52, 211, 153, 0.32); | |
| 45 | --code-bg: #05070c; | |
| 46 | } | |
| 47 | :root[data-theme="light"] { | |
| 48 | --bg: #fbfbfd; --surface: #ffffff; --surface-2: #f4f5f8; | |
| 49 | --border: rgba(15, 23, 42, 0.09); --border-strong: rgba(15, 23, 42, 0.16); | |
| 50 | --text: #0b0f19; --text-2: #4b5565; --text-3: #6b7280; | |
| 51 | --accent: #047857; | |
| 52 | --accent-line: rgba(4, 120, 87, 0.34); | |
| 53 | --code-bg: #0b0f19; | |
| 54 | } | |
| 55 | ||
| 56 | * { box-sizing: border-box; } | |
| 57 | html { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 58 | body { | |
| 59 | margin: 0; min-height: 100vh; overflow-x: hidden; | |
| 60 | color: var(--text); background: var(--bg); | |
| 61 | font-family: var(--sans); font-size: 16px; line-height: 1.5; | |
| 62 | -webkit-font-smoothing: antialiased; | |
| 63 | } | |
| 64 | a { color: inherit; text-decoration: none; } | |
| 65 | ||
| 66 | .shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } | |
| 67 | .shell-narrow { width: min(920px, calc(100% - 48px)); margin: 0 auto; } | |
| 68 | .mono { font-family: var(--mono); } | |
| 69 | ||
| 70 | /* nav */ | |
| 71 | .nav { | |
| 72 | position: sticky; top: 0; z-index: 30; | |
| 73 | border-bottom: 1px solid var(--border); | |
| 74 | background: color-mix(in srgb, var(--bg) 82%, transparent); | |
| 75 | backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 76 | } | |
| 77 | .nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; min-height: 68px; } | |
| 78 | .brand { display: inline-flex; align-items: center; gap: 0.6rem; } | |
| 79 | .brand-mark { | |
| 80 | /* Mono for the glyph: Space Grotesk draws lowercase "l" as a bare vertical | |
| 81 | stroke, which reads as a capital "I" at 30px. */ | |
| 82 | font-family: var(--mono); | |
| 83 | display: grid; place-items: center; width: 30px; height: 30px; | |
| 84 | border: 1px solid var(--accent-line); border-radius: var(--r-sm); | |
| 85 | color: var(--accent); font-weight: 700; font-size: 1rem; line-height: 1; | |
| 86 | } | |
| 87 | .brand-name { font-weight: 700; font-size: 1.12rem; letter-spacing: -0.02em; } | |
| 88 | .nav-links { display: flex; align-items: center; gap: 1.6rem; margin-left: auto; color: var(--text-2); font-size: 0.9rem; } | |
| 89 | .nav-links a { transition: color 0.16s ease; } | |
| 90 | .nav-links a:hover { color: var(--text); } | |
| 91 | .nav-cta { margin-left: 1.6rem; } | |
| 92 | ||
| 93 | .nav-menu { display: none; margin-left: auto; position: relative; } | |
| 94 | .nav-menu > summary { | |
| 95 | list-style: none; display: grid; place-items: center; width: 40px; height: 40px; | |
| 96 | border: 1px solid var(--border-strong); border-radius: var(--r-sm); cursor: pointer; | |
| 97 | } | |
| 98 | .nav-menu > summary::-webkit-details-marker { display: none; } | |
| 99 | .nav-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 100 | .burger, .burger::before, .burger::after { | |
| 101 | display: block; width: 18px; height: 1.5px; background: var(--text); | |
| 102 | transition: transform 0.2s ease, opacity 0.2s ease; | |
| 103 | } | |
| 104 | .burger { position: relative; } | |
| 105 | .burger::before { content: ""; position: absolute; top: -5px; } | |
| 106 | .burger::after { content: ""; position: absolute; top: 5px; } | |
| 107 | .nav-menu[open] .burger { background: transparent; } | |
| 108 | .nav-menu[open] .burger::before { transform: translateY(5px) rotate(45deg); } | |
| 109 | .nav-menu[open] .burger::after { transform: translateY(-5px) rotate(-45deg); } | |
| 110 | .nav-menu-panel { | |
| 111 | position: absolute; right: 0; top: calc(100% + 12px); z-index: 50; | |
| 112 | display: flex; flex-direction: column; gap: 0.25rem; min-width: 232px; padding: 0.6rem; | |
| 113 | max-height: calc(100dvh - 92px); overflow-y: auto; overscroll-behavior: contain; | |
| 114 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 115 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 116 | } | |
| 117 | .nav-menu-panel a { padding: 0.6rem 0.7rem; border-radius: var(--r-sm); color: var(--text-2); font-size: 0.95rem; } | |
| 118 | .nav-menu-panel a:hover { color: var(--text); background: var(--surface-2); } | |
| 119 | .nav-menu-panel .btn { margin-top: 0.35rem; justify-content: center; } | |
| 120 | ||
| 121 | /* buttons */ | |
| 122 | .btn { | |
| 123 | display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; | |
| 124 | min-height: 46px; padding: 0 1.15rem; border: 1px solid var(--border-strong); border-radius: var(--r-sm); | |
| 125 | color: var(--text); font-family: var(--sans); font-weight: 600; font-size: 0.92rem; | |
| 126 | white-space: nowrap; background: transparent; cursor: pointer; | |
| 127 | transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; | |
| 128 | } | |
| 129 | .btn.sm { min-height: 38px; padding: 0 0.85rem; font-size: 0.86rem; } | |
| 130 | .btn.primary { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } | |
| 131 | .btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } | |
| 132 | .btn.ghost:hover { border-color: var(--accent-line); } | |
| 133 | .btn:active { transform: translateY(1px); } | |
| 134 | ||
| 135 | /* section scaffolding */ | |
| 136 | .section { padding: 3.5rem 0; } | |
| 137 | .section-head { max-width: 62ch; margin-bottom: 1.75rem; } | |
| 138 | .section-title { margin: 0; font-weight: 700; font-size: clamp(1.6rem, 3.5vw, 2.2rem); letter-spacing: -0.03em; line-height: 1.1; } | |
| 139 | .section-intro { margin: 0.9rem 0 0; color: var(--text-2); font-size: 1.02rem; line-height: 1.6; } | |
| 140 | .eyebrow { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 141 | .textlink { color: var(--accent); font-weight: 500; } | |
| 142 | .textlink:hover { text-decoration: underline; } | |
| 143 | ||
| 144 | /* hero */ | |
| 145 | .hero { display: grid; grid-template-columns: 1.05fr 1fr; gap: 3.5rem; align-items: center; padding: 3.5rem 0 4rem; } | |
| 146 | .hero-title { margin: 0; font-weight: 700; font-size: clamp(2.3rem, 5.4vw, 3.4rem); letter-spacing: -0.035em; line-height: 1.04; } | |
| 147 | .hero-sub { margin: 1.25rem 0 0; color: var(--text-2); font-size: 1.08rem; line-height: 1.6; max-width: 54ch; } | |
| 148 | .hero-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; } | |
| 149 | ||
| 150 | /* single-column feature/use-case hero */ | |
| 151 | .feat-hero { padding: 2.5rem 0 1.5rem; max-width: 62ch; } | |
| 152 | .feat-hero .kicker { display: inline-flex; align-items: center; gap: 0.5rem; font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); } | |
| 153 | .feat-hero h1 { margin: 1rem 0 0; font-weight: 700; font-size: clamp(2.1rem, 5vw, 3.1rem); line-height: 1.05; letter-spacing: -0.03em; } | |
| 154 | .feat-hero p { margin: 1.1rem 0 0; color: var(--text-2); font-size: 1.05rem; line-height: 1.6; } | |
| 155 | .feat-hero .hero-actions { margin-top: 1.75rem; } | |
| 156 | ||
| 157 | .subnav { display: flex; align-items: center; gap: 0.5rem; padding-top: 1.75rem; color: var(--text-3); font-size: 0.88rem; } | |
| 158 | .subnav a { color: var(--text-2); transition: color 0.16s ease; } | |
| 159 | .subnav a:hover { color: var(--text); } | |
| 160 | .subnav .sep { color: var(--text-3); } | |
| 161 | /* The trail is an <ol> so its order is exposed to assistive tech. The list and | |
| 162 | its items carry the flex layout the nav used to hold directly, so the visual | |
| 163 | result is unchanged. `display: contents` would be shorter and is avoided | |
| 164 | deliberately — several engines drop list items from the accessibility tree | |
| 165 | when it is applied, which would undo the reason for using a list. */ | |
| 166 | .subnav ol { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; } | |
| 167 | .subnav li { display: flex; align-items: center; gap: 0.5rem; } | |
| 168 | ||
| 169 | /* code panel */ | |
| 170 | .code { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 171 | .code-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.14); } | |
| 172 | .code-body { margin: 0; padding: 1.1rem 1.25rem; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: var(--code-ink); } | |
| 173 | .chart-cap { font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 174 | /* Only on the code surface. `.chart-cap` is also used inside `.panel-head`, | |
| 175 | which sits on the normal page surface where --text-3 is the correct token. */ | |
| 176 | .code-bar .chart-cap { color: var(--code-cap); } | |
| 177 | ||
| 178 | /* panel (data viz container) */ | |
| 179 | .panel { border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); padding: 1.4rem 1.5rem; } | |
| 180 | .panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } | |
| 181 | .panel-tag { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); } | |
| 182 | .live { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-2); } | |
| 183 | .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | |
| 184 | .stat-big { margin: 0; font-weight: 700; font-size: 2.6rem; letter-spacing: -0.03em; } | |
| 185 | .stat-label { margin: 0.15rem 0 0; color: var(--text-3); font-size: 0.9rem; } | |
| 186 | ||
| 187 | /* trackless bars */ | |
| 188 | .bar-row { display: grid; grid-template-columns: 148px 1fr 52px; align-items: center; gap: 0.9rem; padding: 0.5rem 0; } | |
| 189 | .bar-row + .bar-row { border-top: 1px solid var(--border); } | |
| 190 | .bar-label { color: var(--text-2); font-size: 0.88rem; } | |
| 191 | .bar-track { height: 8px; border-radius: 999px; background: transparent; } | |
| 192 | .bar-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); opacity: 0.82; min-width: 4px; } | |
| 193 | .bar-value { font-family: var(--mono); font-size: 0.82rem; color: var(--text); text-align: right; } | |
| 194 | ||
| 195 | /* stat/severity tiles */ | |
| 196 | .vitals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 197 | .vital { padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 198 | .vital-k { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); } | |
| 199 | .vital-v { margin-top: 0.5rem; font-weight: 700; font-size: 1.9rem; letter-spacing: -0.02em; color: var(--text); } | |
| 200 | .vital-tag { margin-top: 0.4rem; display: inline-flex; font-size: 0.78rem; color: var(--accent); } | |
| 201 | ||
| 202 | /* numbered flow */ | |
| 203 | .flow { display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 204 | .flow-step { display: grid; grid-template-columns: auto 1fr; gap: 1.1rem; padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 205 | .flow-num { font-family: var(--mono); font-size: 0.82rem; color: var(--accent); padding-top: 0.15rem; } | |
| 206 | .flow-step h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 207 | .flow-step p { margin: 0.4rem 0 0; color: var(--text-2); font-size: 0.95rem; line-height: 1.55; } | |
| 208 | ||
| 209 | /* metric tiles */ | |
| 210 | .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem 2.5rem; } | |
| 211 | .metric .m-value { font-weight: 700; font-size: clamp(1.8rem, 4vw, 2.4rem); letter-spacing: -0.02em; color: var(--text); } | |
| 212 | .metric .m-label { margin-top: 0.35rem; color: var(--text-2); font-size: 0.9rem; line-height: 1.5; } | |
| 213 | ||
| 214 | /* chips grid */ | |
| 215 | .chips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 216 | .chip { border: 1px solid var(--border); border-radius: var(--r); padding: 1.4rem 1.5rem; background: var(--surface); } | |
| 217 | .chip h3 { margin: 0; font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em; } | |
| 218 | .chip p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 219 | ||
| 220 | /* feature bento (landing) */ | |
| 221 | .bento { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 222 | .cell { display: block; padding: 1.5rem 1.6rem; background: var(--surface); transition: background 0.16s ease; } | |
| 223 | a.cell { cursor: pointer; } | |
| 224 | a.cell:hover { background: var(--surface-2); } | |
| 225 | .cell h3 { margin: 0; font-weight: 600; font-size: 1.08rem; letter-spacing: -0.015em; } | |
| 226 | .cell p { margin: 0.5rem 0 0; color: var(--text-2); font-size: 0.93rem; line-height: 1.55; } | |
| 227 | .cell.lead { grid-column: span 1; } | |
| 228 | .cell.tint { background: var(--accent-soft); } | |
| 229 | a.cell.tint:hover { background: color-mix(in srgb, var(--accent) 14%, var(--surface)); } | |
| 230 | /* Featured bento cell: a soft rose wash (bughq accent) tinted into a corner, so | |
| 231 | the grid has visual variation rather than six identical text cards. */ | |
| 232 | .cell.feature { background: radial-gradient(120% 140% at 100% 0%, var(--accent-soft), transparent 58%), var(--surface); } | |
| 233 | a.cell.feature:hover { background: radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%), var(--surface-2); } | |
| 234 | .cell.feature h3 { color: var(--accent); } | |
| 235 | .cell-more { display: inline-flex; align-items: center; gap: 0.35rem; margin-top: 1rem; font-family: var(--mono); font-size: 0.74rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-3); transition: color 0.16s ease, gap 0.16s ease; } | |
| 236 | .cell-more::after { content: "\2192"; } | |
| 237 | a.cell:hover .cell-more { color: var(--accent); gap: 0.55rem; } | |
| 238 | ||
| 239 | /* comparison table */ | |
| 240 | .cmp { display: grid; grid-template-columns: 1.1fr 1fr 1fr; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 241 | .cmp-cell { padding: 1rem 1.2rem; border-top: 1px solid var(--border); background: var(--surface); font-size: 0.92rem; } | |
| 242 | .cmp-cell:nth-child(-n+3) { border-top: none; font-weight: 600; } | |
| 243 | .cmp-dim { color: var(--text-3); } | |
| 244 | .cmp-ghost { color: var(--text); } | |
| 245 | ||
| 246 | /* related features */ | |
| 247 | .related { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; } | |
| 248 | .related a { display: block; padding: 1.3rem 1.4rem; background: var(--surface); transition: background 0.16s ease; } | |
| 249 | .related a:hover { background: var(--surface-2); } | |
| 250 | .related .r-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: -0.01em; } | |
| 251 | .related .r-desc { margin-top: 0.3rem; color: var(--text-3); font-size: 0.86rem; line-height: 1.45; } | |
| 252 | ||
| 253 | /* use-case cards */ | |
| 254 | .usecases { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } | |
| 255 | .usecase { border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; background: var(--surface); } | |
| 256 | .usecase img { display: block; width: 100%; height: 168px; object-fit: cover; } | |
| 257 | .usecase-body { padding: 1.4rem 1.5rem 1.6rem; } | |
| 258 | .usecase-body h2 { margin: 0; font-weight: 700; font-size: 1.15rem; letter-spacing: -0.02em; } | |
| 259 | .usecase-body p { margin: 0.55rem 0 0; color: var(--text-2); font-size: 0.94rem; line-height: 1.6; } | |
| 260 | .usecase-body .textlink { margin-top: 0.9rem; display: inline-block; } | |
| 261 | ||
| 262 | /* cta band */ | |
| 263 | .cta-band { border: 1px solid var(--border); border-radius: var(--r); padding: 2.5rem; background: var(--surface); text-align: center; } | |
| 264 | .cta-band h2 { margin: 0 auto; max-width: 30ch; font-weight: 700; font-size: clamp(1.5rem, 3.5vw, 2rem); letter-spacing: -0.03em; } | |
| 265 | .cta-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; } | |
| 266 | ||
| 267 | /* subscribe form | |
| 268 | Sized to the band's own rhythm: the input borrows the .btn min-height so the | |
| 269 | field and its button line up without a magic number, and the row collapses to | |
| 270 | a stack under 30rem rather than squeezing a 40-character field. */ | |
| 271 | .sub-form { display: flex; flex-wrap: wrap; gap: 0.6rem; justify-content: center; margin: 1.5rem auto 0; max-width: 27rem; } | |
| 272 | .sub-field { flex: 1 1 15rem; min-width: 0; min-height: 44px; padding: 0 0.9rem; border: 1px solid var(--border-strong, var(--border)); border-radius: calc(var(--r) - 4px); background: var(--bg); color: var(--text); font: inherit; font-size: 0.95rem; transition: border-color 0.16s ease, box-shadow 0.16s ease; } | |
| 273 | .sub-field::placeholder { color: var(--text-3); } | |
| 274 | .sub-field:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 275 | /* Defined here rather than assumed from the utility layer: this stylesheet is | |
| 276 | the page's own contract, and a visually-hidden helper that silently is not | |
| 277 | emitted shows up as a stray label sitting above the field. */ | |
| 278 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 279 | .sub-note { margin: 0.85rem auto 0; max-width: 40ch; color: var(--text-3); font-size: 0.84rem; line-height: 1.5; } | |
| 280 | .sub-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 2px; } | |
| 281 | /* One line, reserved by min-height so a reply does not shift the section. */ | |
| 282 | .sub-status { margin: 0.85rem auto 0; min-height: 1.25rem; color: var(--text-2); font-size: 0.88rem; } | |
| 283 | .sub-status[data-state="error"] { color: var(--accent); } | |
| 284 | @media (max-width: 30rem) { | |
| 285 | .sub-form { flex-direction: column; } | |
| 286 | /* A row's 15rem width basis becomes height when the form stacks. */ | |
| 287 | .sub-field { flex: none; } | |
| 288 | .sub-form .btn { width: 100%; justify-content: center; } | |
| 289 | } | |
| 290 | ||
| 291 | /* footer */ | |
| 292 | .footer { padding: 2.5rem 0; } | |
| 293 | .footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--border); color: var(--text-3); font-size: 0.88rem; } | |
| 294 | ||
| 295 | /* motion — `.reveal` is NOT declared here. | |
| 296 | * | |
| 297 | * It used to be, and app-chrome.css declared it too: two `.reveal` rules and | |
| 298 | * two identically-named `@keyframes reveal` in two sheets that are both loaded | |
| 299 | * on every marketing page. This file wins on order, so marketing pages animated | |
| 300 | * over 0.6s/12px and the dashboard — the one app page that uses .reveal, and | |
| 301 | * which never loads this file — animated over 0.5s/10px. Nothing intended that | |
| 302 | * split; it was drift between two copies. | |
| 303 | * | |
| 304 | * The single definition lives in app-chrome.css, which is the site-wide sheet, | |
| 305 | * and it carries these values because 23 marketing pages used them against the | |
| 306 | * dashboard's one. A same-named @keyframes in a later sheet REPLACES rather | |
| 307 | * than merges, so the duplicate was also a live footgun: editing the copy here | |
| 308 | * would have silently changed marketing pages only. | |
| 309 | */ | |
| 310 | ||
| 311 | /* responsive */ | |
| 312 | @media (max-width: 900px) { | |
| 313 | body:has(.nav-menu[open]) { overflow: hidden; } | |
| 314 | .hero { grid-template-columns: 1fr; gap: 2rem; padding-top: 2.5rem; } | |
| 315 | .bento, .related, .cmp { grid-template-columns: 1fr; } | |
| 316 | .metrics { grid-template-columns: 1fr 1fr; } | |
| 317 | .chips, .usecases { grid-template-columns: 1fr; } | |
| 318 | .nav-links { display: none; } | |
| 319 | .nav-cta { display: none; } | |
| 320 | .nav-menu { display: block; } | |
| 321 | } | |
| 322 | @media (max-width: 560px) { | |
| 323 | .metrics, .vitals { grid-template-columns: 1fr; } | |
| 324 | .flow-step { grid-template-columns: 1fr; gap: 0.4rem; } | |
| 325 | .bar-row { grid-template-columns: 120px 1fr 44px; } | |
| 326 | } | |
| 327 | ||
| 328 | /* login link beside the Sign up nav button */ | |
| 329 | .nav-login { color: var(--text-2); font-size: 0.9rem; transition: color 0.16s ease; } | |
| 330 | .nav-login:hover { color: var(--text); } | |
| 331 | .nav-cta { margin-left: 0.95rem; } | |
| 332 | ||
| 333 | /* CSS-only mega menu, from SiteNav.stx's <style> block. It lives here rather | |
| 334 | * than in the partial because none of what follows is expressible as a | |
| 335 | * utility: two pseudo-elements (the caret and the invisible hover bridge that | |
| 336 | * lets the pointer cross the 14px gap), ancestor-state descendants | |
| 337 | * (`.mega:hover .mega-panel`), a `:not(:first-child)`, and a max-width media | |
| 338 | * query — Crosswind emits nothing for any of them. The leaf styling that IS | |
| 339 | * expressible (.mega-t, .mega-d, the panel links, the menu group's own box) | |
| 340 | * became utilities in the markup and is deliberately absent here. | |
| 341 | * | |
| 342 | * A linked stylesheet is also the router-safe home: the SPA router destroys | |
| 343 | * every <head> style that is not the Crosswind tag on navigation. | |
| 344 | */ | |
| 345 | .mega { position: relative; display: inline-flex; align-items: center; } | |
| 346 | .mega-trigger { display: inline-flex; align-items: center; gap: 0.32rem; cursor: pointer; } | |
| 347 | .mega-trigger::after { | |
| 348 | content: "\25BE"; font-size: 0.62em; line-height: 1; color: var(--text-3); | |
| 349 | transition: transform 0.18s ease, color 0.18s ease; | |
| 350 | } | |
| 351 | .mega:hover .mega-trigger, .mega:focus-within .mega-trigger { color: var(--text); } | |
| 352 | .mega:hover .mega-trigger::after, .mega:focus-within .mega-trigger::after { transform: rotate(180deg); color: var(--accent); } | |
| 353 | ||
| 354 | .mega-panel { | |
| 355 | position: absolute; top: 100%; left: 50%; z-index: 50; | |
| 356 | margin-top: 14px; | |
| 357 | display: grid; grid-template-columns: repeat(2, minmax(216px, 1fr)); gap: 0.12rem; | |
| 358 | min-width: 484px; padding: 0.55rem; | |
| 359 | background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r); | |
| 360 | box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22); | |
| 361 | opacity: 0; visibility: hidden; pointer-events: none; | |
| 362 | transform: translateX(-50%) translateY(6px); | |
| 363 | transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; | |
| 364 | } | |
| 365 | .mega-panel::before { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; } | |
| 366 | .mega:hover .mega-panel, .mega:focus-within .mega-panel { | |
| 367 | opacity: 1; visibility: visible; pointer-events: auto; | |
| 368 | transform: translateX(-50%) translateY(0); | |
| 369 | } | |
| 370 | .nav-menu-group:not(:first-child) { margin-top: 0.35rem; border-top: 1px solid var(--border); } | |
| 371 | ||
| 372 | @media (prefers-reduced-motion: reduce) { | |
| 373 | .mega-trigger::after, .mega-panel { transition: none; } | |
| 374 | } | |
| 375 | @media (max-width: 900px) { | |
| 376 | /* desktop mega hidden with .nav-links; the <details> menu takes over */ | |
| 377 | .mega-panel { display: none; } | |
| 378 | } | |
public/tokens.cssdeleted+0-250
| @@ -1,250 +0,0 @@ | ||
| 1 | /* loghq design tokens — the single source for the app surface. | |
| 2 | * | |
| 3 | * Linked once from config/ui.ts `app.head.link`, so every page gets it without | |
| 4 | * repeating it. Ten app views used to declare their own byte-similar copy of | |
| 5 | * this block: 90 custom-property declarations in four subtly diverging | |
| 6 | * variants, which is how the dashboard's `--mono` stack ended up one entry | |
| 7 | * longer than everyone else's. | |
| 8 | * | |
| 9 | * It is NOT in config/crosswind.ts `preflights`, which is where the styling | |
| 10 | * guide says design tokens belong. Verified against the installed build: | |
| 11 | * `preflights` is spread into the generator config and then never emitted — | |
| 12 | * toCSS() writes the built-in reset only. A probe token placed there appears | |
| 13 | * nowhere in the served CSS on any route. | |
| 14 | * | |
| 15 | * These names are the contract config/crosswind.ts maps its semantic utilities | |
| 16 | * onto — `bg-canvas`, `bg-panel`, `border-line`, `text-ink`, `text-muted`, | |
| 17 | * `text-subtle`, `text-accent`. Renaming one here silently breaks the matching | |
| 18 | * utility everywhere, because an undefined var() resolves to the property's | |
| 19 | * initial value rather than erroring. | |
| 20 | * | |
| 21 | * Marketing pages additionally load /marketing.css, which arrives after this | |
| 22 | * file and defines a richer set of its own (--surface, --accent-line, --r, …). | |
| 23 | * --accent-soft moved the other way, out of marketing.css and into here, the | |
| 24 | * day the compare tables needed a `bg-accent-soft` utility: a utility backed | |
| 25 | * by a token that only marketing pages declare is dead everywhere else and | |
| 26 | * fails silently. marketing.css still consumes it (.live-dot, .cell.tint, | |
| 27 | * .cell.feature) and resolves it from this file. | |
| 28 | * Where the two overlap the values agree; where they differ, marketing.css | |
| 29 | * wins on marketing pages, which is the intent. | |
| 30 | * | |
| 31 | * Both the media query and the [data-theme] attribute are covered, and that | |
| 32 | * pairing is deliberate: the attribute is what the pre-paint theme script and | |
| 33 | * the theme store write, and it has to beat the OS preference so an explicit | |
| 34 | * choice survives. | |
| 35 | */ | |
| 36 | ||
| 37 | :root { | |
| 38 | /* The one declaration that reaches the parts of the page we do not draw. | |
| 39 | * | |
| 40 | * Native UI — the select popup, scrollbars, the date and colour pickers, form | |
| 41 | * control backgrounds, the spellcheck underline, the caret — is painted by the | |
| 42 | * browser from `color-scheme`, not from our custom properties. Undeclared it | |
| 43 | * defaults to `light`, so on a dark loghq the project dropdown opened as a | |
| 44 | * white popup on a #090b11 page and the scrollbars stayed light. No amount of | |
| 45 | * token work reaches those pixels; this is the only thing that does. | |
| 46 | * | |
| 47 | * It is declared on all four blocks below and not once on `:root` with both | |
| 48 | * values, because `color-scheme: light dark` means "either is fine, follow the | |
| 49 | * OS" — which is wrong the moment someone picks a theme explicitly. The | |
| 50 | * attribute blocks have to be able to override the media query, exactly like | |
| 51 | * every colour here. */ | |
| 52 | color-scheme: light; | |
| 53 | --bg: #fbfbfd; | |
| 54 | --panel: #ffffff; | |
| 55 | --border: rgba(15, 23, 42, 0.09); | |
| 56 | --text: #0b0f19; | |
| 57 | --text-2: #4b5565; | |
| 58 | /* --text-3 was #97a1b2, which measured 2.52:1 against --bg and 2.61:1 | |
| 59 | * against --panel. WCAG AA wants 4.5:1 for body text, so every "subtle" | |
| 60 | * label in the app failed it — 73 elements in light and 82 in dark, measured | |
| 61 | * in a real browser across seven pages. It is the same token in both themes | |
| 62 | * and it failed in both (dark was #667085, 3.73:1 on --panel). | |
| 63 | * | |
| 64 | * #6b7280 gives 4.83:1 on --panel and 4.68:1 on --bg; the dark #79839a gives | |
| 65 | * 4.88:1 and 5.18:1. Both are the smallest step that clears AA on the worse | |
| 66 | * of the two surfaces, so "subtle" still reads as subtle. */ | |
| 67 | --text-3: #6b7280; | |
| 68 | /* Emerald, not the rose this shipped with. | |
| 69 | * | |
| 70 | * The old --accent was #e11d48, which is byte-for-byte --sev-error below. In | |
| 71 | * a product whose whole surface is log severity, the brand colour WAS the | |
| 72 | * error colour: every button, link and focus ring carried the one hue the | |
| 73 | * severity scale reserves for something being wrong. | |
| 74 | * | |
| 75 | * Green collides with --ok instead, which is the milder trade. --ok is a UI | |
| 76 | * success state, not a severity: nothing in the sev-* scale is green, so the | |
| 77 | * severity reading stays unambiguous. Brand-as-healthy misleads nobody; | |
| 78 | * brand-as-error did. | |
| 79 | * | |
| 80 | * #047857 rather than the more obvious #059669: emerald-600 measures 3.65:1 | |
| 81 | * on --bg, under the 4.5 floor this file holds everything else to. This one | |
| 82 | * measures 5.31:1 on --bg and 5.48:1 on --panel, which is also an improvement | |
| 83 | * on the rose it replaces (4.54:1, passing by 0.04). */ | |
| 84 | --accent: #047857; | |
| 85 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 86 | /* Ink for text sitting ON a saturated fill — the accent, --warn, and the | |
| 87 | * Slack/Discord brand colours. | |
| 88 | * | |
| 89 | * It cannot be a hardcoded #fff, which is what the app markup and | |
| 90 | * app-chrome.css assumed. In dark mode the fills get LIGHTER, not darker: | |
| 91 | * --accent is #34d399 and the dashboard's indigo is #818cf8, where white | |
| 92 | * measures 1.92:1 and 2.98:1. The dark value here measures 9.83:1 and 6.34:1 | |
| 93 | * on those same fills, and 6.83:1 on the dark --warn. | |
| 94 | * | |
| 95 | * marketing.css has had this token, correctly themed, since the marketing | |
| 96 | * pages were built; it just never reached the app surface, because that | |
| 97 | * sheet only loads on marketing routes. Declared here it covers both, and | |
| 98 | * marketing.css no longer declares its own copy — see the .reveal note there | |
| 99 | * for what two copies of one token cost. */ | |
| 100 | --accent-ink: #ffffff; | |
| 101 | --ok: #16a34a; | |
| 102 | --warn: #b91c1c; | |
| 103 | --sans: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; | |
| 104 | --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 105 | ||
| 106 | /* Log severity. These were six hex literals inside a levelColor() switch in | |
| 107 | * dashboard.stx, with two of them repeated in the stats array — so the same | |
| 108 | * "warning amber" was written twice and could drift. | |
| 109 | * | |
| 110 | * They were also a single set used on BOTH themes. The dark values below are | |
| 111 | * exactly what shipped before, so dark is unchanged; light gets darker, | |
| 112 | * higher-contrast variants, because #fb7185 on a #fbfbfd background is not a | |
| 113 | * readable label. */ | |
| 114 | --sev-critical: #be123c; | |
| 115 | --sev-error: #e11d48; | |
| 116 | --sev-warning: #b45309; | |
| 117 | --sev-notice: #0369a1; | |
| 118 | --sev-info: #1d4ed8; | |
| 119 | --sev-debug: #64748b; | |
| 120 | } | |
| 121 | ||
| 122 | @media (prefers-color-scheme: dark) { | |
| 123 | :root { | |
| 124 | color-scheme: dark; | |
| 125 | --bg: #090b11; | |
| 126 | --panel: #10131c; | |
| 127 | --border: rgba(148, 163, 184, 0.12); | |
| 128 | --text: #f3f5f9; | |
| 129 | --text-2: #b3bccb; | |
| 130 | --text-3: #79839a; | |
| 131 | --accent: #34d399; | |
| 132 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 133 | --accent-ink: #04140d; | |
| 134 | --ok: #4ade80; | |
| 135 | --warn: #f87171; | |
| 136 | --sev-critical: #f43f5e; | |
| 137 | --sev-error: #fb7185; | |
| 138 | --sev-warning: #f59e0b; | |
| 139 | --sev-notice: #38bdf8; | |
| 140 | --sev-info: #60a5fa; | |
| 141 | --sev-debug: #94a3b8; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | :root[data-theme='dark'] { | |
| 146 | color-scheme: dark; | |
| 147 | --bg: #090b11; | |
| 148 | --panel: #10131c; | |
| 149 | --border: rgba(148, 163, 184, 0.12); | |
| 150 | --text: #f3f5f9; | |
| 151 | --text-2: #b3bccb; | |
| 152 | --text-3: #79839a; | |
| 153 | --accent: #34d399; | |
| 154 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 155 | --accent-ink: #04140d; | |
| 156 | --ok: #4ade80; | |
| 157 | --warn: #f87171; | |
| 158 | --sev-critical: #f43f5e; | |
| 159 | --sev-error: #fb7185; | |
| 160 | --sev-warning: #f59e0b; | |
| 161 | --sev-notice: #38bdf8; | |
| 162 | --sev-info: #60a5fa; | |
| 163 | --sev-debug: #94a3b8; | |
| 164 | } | |
| 165 | ||
| 166 | :root[data-theme='light'] { | |
| 167 | color-scheme: light; | |
| 168 | --bg: #fbfbfd; | |
| 169 | --panel: #ffffff; | |
| 170 | --border: rgba(15, 23, 42, 0.09); | |
| 171 | --text: #0b0f19; | |
| 172 | --text-2: #4b5565; | |
| 173 | --text-3: #6b7280; | |
| 174 | --accent: #047857; | |
| 175 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 176 | --accent-ink: #ffffff; | |
| 177 | --ok: #16a34a; | |
| 178 | --warn: #b91c1c; | |
| 179 | --sev-critical: #be123c; | |
| 180 | --sev-error: #e11d48; | |
| 181 | --sev-warning: #b45309; | |
| 182 | --sev-notice: #0369a1; | |
| 183 | --sev-info: #1d4ed8; | |
| 184 | --sev-debug: #64748b; | |
| 185 | } | |
| 186 | ||
| 187 | body { | |
| 188 | background: var(--bg); | |
| 189 | color: var(--text); | |
| 190 | font-family: var(--sans); | |
| 191 | -webkit-font-smoothing: antialiased; | |
| 192 | } | |
| 193 | ||
| 194 | /* Skeleton shimmer, from account.stx's <style> block. Utilities cannot declare | |
| 195 | * a @keyframes, and config/crosswind.ts `preflights` — the documented home for | |
| 196 | * one — is never emitted on this serve path, so it lives here. | |
| 197 | * | |
| 198 | * Named loghq-pulse rather than pulse on purpose. Crosswind emits its own | |
| 199 | * `@keyframes pulse` (0%,100% opacity 1; 50% opacity .5) as soon as any page | |
| 200 | * uses `animate-pulse`, into a sheet that is linked AFTER this file. A | |
| 201 | * same-named keyframe there would silently replace this one and change the | |
| 202 | * animation rather than error. | |
| 203 | */ | |
| 204 | @keyframes loghq-pulse { | |
| 205 | 0%, | |
| 206 | 100% { | |
| 207 | opacity: 0.5; | |
| 208 | } | |
| 209 | 50% { | |
| 210 | opacity: 0.9; | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | /* Theme-toggle icon swap. Three identical copies of this lived in | |
| 215 | * dashboard.stx, settings.stx and projects/index.stx. | |
| 216 | * | |
| 217 | * It stays CSS because no utility can express it: the selector is an | |
| 218 | * attribute-state descendant, `[data-t="light"] .ic-sun`, driven by the | |
| 219 | * `theme()` signal through `:data-t` on the button. The alternative — a | |
| 220 | * `:show` binding on each icon — would need `style="display:none"` on both to | |
| 221 | * avoid showing both icons before hydration, trading a state rule for two | |
| 222 | * inline styles and a hydration dependency on something that is currently | |
| 223 | * pure CSS. | |
| 224 | */ | |
| 225 | .theme-btn .ic-sun, | |
| 226 | .theme-btn .ic-moon { | |
| 227 | display: none; | |
| 228 | } | |
| 229 | ||
| 230 | /* Keyed off the ROOT element, not off the button. | |
| 231 | * | |
| 232 | * These used to read `.theme-btn[data-t='light']`, where data-t came from a | |
| 233 | * `:data-t="theme()"` binding — a client signal. So before hydration the button | |
| 234 | * carried no data-t at all, neither rule matched, and the pair above left BOTH | |
| 235 | * icons display:none: the theme control rendered as an empty box on every cold | |
| 236 | * load. There is no server-known theme to put in a static counterpart either, | |
| 237 | * so the fix is not a static attribute; it is keying off something that IS set | |
| 238 | * before first paint. | |
| 239 | * | |
| 240 | * stx's color-mode boot script sets data-theme on the root above every | |
| 241 | * stylesheet (config/ui.ts app.colorMode), and useColorMode keeps it current | |
| 242 | * afterwards, so the correct icon paints immediately and the mirror signal the | |
| 243 | * binding needed is gone from all three pages. */ | |
| 244 | :root[data-theme='light'] .theme-btn .ic-sun { | |
| 245 | display: block; | |
| 246 | } | |
| 247 | ||
| 248 | :root[data-theme='dark'] .theme-btn .ic-moon { | |
| 249 | display: block; | |
| 250 | } | |
| @@ -1,4 +1,31 @@ | ||
| 1 | // The coming-soon preview bypass. | |
| 2 | // | |
| 3 | // A standalone browser asset, not an stx template: no imports, no stx runtime, | |
| 4 | // no composables. The DOM calls below are marked rather than converted because | |
| 5 | // there is nothing here to convert them to -- a useCookie() or navigate() has | |
| 6 | // no runtime to come from, and this file has to keep working on a page served | |
| 7 | // while the app is in coming-soon mode. | |
| 8 | // | |
| 9 | // Marked per call site rather than added to allowPatterns: that list is a | |
| 10 | // rule-global substring filter with no file scoping, so allowing | |
| 11 | // document.cookie for this asset would stop the validator reporting it | |
| 12 | // anywhere in the app. | |
| 13 | // | |
| 14 | // The markers are defensive rather than currently load-bearing, and it is | |
| 15 | // worth being exact about why. stx 0.2.520 resolves <script src> and scans the | |
| 16 | // asset behind it, attributing what it finds to the view that pulled it in -- | |
| 17 | // that is how this same file came to be reported in the status app, where the | |
| 18 | // markers silence it. stx 0.2.532, which this app runs, does not scan assets | |
| 19 | // that way: measured here by reverting the markers and re-rendering, which | |
| 20 | // reported nothing either way. So these mark an exemption that is real but | |
| 21 | // unenforced today, and that returns the moment the scan does. | |
| 22 | // | |
| 23 | // Nothing in this app loads the file today: no <script src> in any view or | |
| 24 | // layout, and no public/ copy to serve. If this app is not meant to have a | |
| 25 | // coming-soon mode at all, deleting it beats keeping it marked. | |
| 26 | ||
| 1 | 27 | (() => { |
| 28 | // stx-strict-ignore-next-line document.body | |
| 2 | 29 | const body = document.body |
| 3 | 30 | if (!body || body.dataset.siteMode !== 'coming-soon') |
| 4 | 31 | return |
| @@ -10,22 +37,27 @@ | ||
| 10 | 37 | const token = body.dataset.comingSoonBypass || '' |
| 11 | 38 | const expectedValue = token || '1' |
| 12 | 39 | const cookieName = 'stacks_coming_soon_preview' |
| 40 | // stx-strict-ignore-next-line window.location | |
| 13 | 41 | const params = new URLSearchParams(window.location.search) |
| 14 | 42 | const requestedPreview = params.get('preview') || params.get('bypass') || '' |
| 15 | 43 | |
| 16 | 44 | if (requestedPreview === 'off') { |
| 45 | // stx-strict-ignore-next-line document.cookie | |
| 17 | 46 | document.cookie = `${cookieName}=; Path=/; Max-Age=0; SameSite=Lax` |
| 18 | 47 | return |
| 19 | 48 | } |
| 20 | 49 | |
| 21 | 50 | if (requestedPreview && requestedPreview === expectedValue) { |
| 51 | // stx-strict-ignore-next-line document.cookie | |
| 22 | 52 | document.cookie = `${cookieName}=${encodeURIComponent(expectedValue)}; Path=/; Max-Age=2592000; SameSite=Lax` |
| 23 | 53 | body.classList.add('site-mode-unlocked') |
| 54 | // stx-strict-ignore-next-line window.history window.location | |
| 24 | 55 | window.history.replaceState({}, '', `${window.location.pathname}${window.location.hash}`) |
| 25 | 56 | return |
| 26 | 57 | } |
| 27 | 58 | |
| 28 | 59 | const cookies = Object.fromEntries( |
| 60 | // stx-strict-ignore-next-line document.cookie | |
| 29 | 61 | document.cookie |
| 30 | 62 | .split(';') |
| 31 | 63 | .map(cookie => cookie.trim()) |
| @@ -62,7 +62,10 @@ | ||
| 62 | 62 | rules. The shared fonts, charset, viewport and favicon DO live in app.head — |
| 63 | 63 | they are genuinely site-wide, and injectConfigHeadTags dedupes by exact href |
| 64 | 64 | so nothing doubles up. --}} |
| 65 | <link rel="stylesheet" href="/marketing.css"> | |
| 65 | {{-- /marketing.css is gone: it is the MARKETING preflight in | |
| 66 | config/crosswind.ts now, scoped to this layout's group | |
| 67 | (stx-standards rule 3, §11.2). Nothing replaces it here -- | |
| 68 | the generated sheet is injected by the renderer. --}} | |
| 66 | 69 | {{-- No @stack('styles') here, following the same rule default.stx states: add a |
| 67 | 70 | slot when a page pushes to it, not before. Nine pages used to carry an empty |
| 68 | 71 | `` pair — index and all eight compare pages — |
| @@ -1,50 +1,29 @@ | ||
| 1 | {{-- Breadcrumb trail. Reads a `crumbs` array from the including page's server | |
| 2 | block — @include does not accept inline data in this version (an | |
| 3 | `['crumbs' => …]` argument leaves the variable undefined and the loop | |
| 4 | reports "crumbs is not iterable"), and the page needs the array declared | |
| 5 | anyway to drive its @structuredData BreadcrumbList from the same source. | |
| 1 | {{-- Breadcrumb trail, on the shared <Breadcrumb>. | |
| 6 | 2 | |
| 7 | Each crumb is { label, href?, url? }. The last has no href: it is the | |
| 8 | current page. | |
| 3 | Reads a `crumbs` array from the including page's server block. @include does | |
| 4 | not accept inline data in this version (an `['crumbs' => …]` argument leaves | |
| 5 | the variable undefined and the loop reports "crumbs is not iterable"), and | |
| 6 | the page needs the array declared anyway to drive its @structuredData | |
| 7 | BreadcrumbList from the same source. Each crumb is { label, href?, url? }; | |
| 8 | the last has no href because it is the current page. That is the component's | |
| 9 | `items` shape, and the extra `url` the JSON-LD needs is ignored by it. | |
| 9 | 10 | |
| 10 | Still hand-written rather than the shipped <Breadcrumb>, but no longer | |
| 11 | because the component is broken. It used to be: the component loops with | |
| 12 | @foreach(item in displayItems), the parser accepted only the ` as ` form, | |
| 13 | and the directive text shipped literally on all 22 pages. | |
| 11 | Hand-written until now for a reason that is gone: the component loops with | |
| 12 | @foreach(item in displayItems), the parser accepted only the ` as ` form, and | |
| 13 | the directive text shipped literally on all 22 pages. Fixed as of 0.2.395. | |
| 14 | 14 | |
| 15 | That is fixed as of stx 0.2.395, checked by rendering rather than by | |
| 16 | reading: `<Breadcrumb :items="…" />` emits a real | |
| 17 | <nav aria-label="Breadcrumb"><ol>, the separator inside each <li> and | |
| 18 | hidden from assistive tech, aria-current="page" on the last item, and | |
| 19 | data-stx-link on the anchors so the SPA router still intercepts them. | |
| 20 | That is the same markup this file writes by hand. analyticshq's own | |
| 21 | partial already calls the component this way. | |
| 15 | `shell subnav` stays, so the container is unchanged: public/marketing.css | |
| 16 | owns the width, the 28px of top padding and the flex row, and all 22 pages | |
| 17 | that include this extend the marketing layout, so that file is always | |
| 18 | linked. The component's own utilities land on top and resolve to the same | |
| 19 | values here - its links are `fg-muted` (--text-2, what `.subnav a` gave) and | |
| 20 | its separators `fg-soft` (--text-3, what `.sep` gave). | |
| 22 | 21 | |
| 23 | What is left is a styling choice rather than a constraint: the component | |
| 24 | brings its own utilities (flex, gap-2, text-sm, text-fg-muted) where this | |
| 25 | trail is dressed by `shell subnav`. Worth doing deliberately, not as a | |
| 26 | side effect of editing this file. | |
| 27 | ||
| 28 | Ordered list, not a row of anchors: this is a sequence, the position is | |
| 29 | meaningful, and assistive tech should say so. The separators are decorative | |
| 30 | and hidden from it; the current page is marked aria-current. | |
| 31 | ||
| 32 | StxLink rather than a bare anchor. It compiles to `<a href data-stx-link>`, | |
| 33 | and the SPA router only intercepts elements carrying that attribute — a | |
| 34 | plain `<a href="/projects">` does a native full page reload instead. This | |
| 35 | was the last internal link in the app still doing that. Where a full load | |
| 36 | IS wanted (the OAuth redirects in login/register), the anchor stays plain | |
| 37 | and says so with data-no-router. --}} | |
| 38 | <nav class="shell subnav" aria-label="Breadcrumb"> | |
| 39 | <ol> | |
| 40 | @foreach (crumbs as c) | |
| 41 | <li> | |
| 42 | @if ($loop.last) | |
| 43 | <span aria-current="page">{{ c.label }}</span> | |
| 44 | @else | |
| 45 | <StxLink to="">{{ c.label }}</StxLink><span class="sep" aria-hidden="true">/</span> | |
| 46 | @endif | |
| 47 | </li> | |
| 48 | @endforeach | |
| 49 | </ol> | |
| 50 | </nav> | |
| 22 | One thing does change, and it is the reason this was left for a deliberate | |
| 23 | pass. The current crumb was a bare <span> with no class, so it inherited | |
| 24 | `.subnav`'s own `color: var(--text-3)` and rendered QUIETER than the links | |
| 25 | beside it - the one item telling you where you are was the faintest thing in | |
| 26 | the row. That reads as an accident of inheritance rather than a choice. The | |
| 27 | component gives it `text-fg font-medium`, so it is now the most prominent | |
| 28 | crumb, which is what a trail is for. --}} | |
| 29 | <Breadcrumb className="shell subnav" :items="crumbs" /> | |
| @@ -30,6 +30,11 @@ function fmtDate(v: string | null | undefined): string { | ||
| 30 | 30 | |
| 31 | 31 | // Derived reads. Plain functions rather than computed(): each is called from a |
| 32 | 32 | // binding, which re-evaluates when the signal it reads changes. |
| 33 | // Signed in / signed out, decided only once /api/me has answered. Both are | |
| 34 | // false while `loaded` is still false, so the page shows its skeleton rather | |
| 35 | // than briefly claiming the visitor is signed out. | |
| 36 | const signedIn = (): boolean => loaded() && !!user() | |
| 37 | const signedOut = (): boolean => loaded() && !user() | |
| 33 | 38 | const displayName = (): string => user()?.name || 'Your account' |
| 34 | 39 | const email = (): string => user()?.email || '' |
| 35 | 40 | // A remote avatar can 404 — a revoked GitHub account, a Gravatar that was |
| @@ -110,8 +115,13 @@ onMount(() => { | ||
| 110 | 115 | <header class="flex items-center justify-between mx-auto px-5 md:px-6 h-16 max-w-2xl"> |
| 111 | 116 | <StxLink to="/" class="text-lg wordmark">loghq</StxLink> |
| 112 | 117 | <div class="flex gap-2.5 items-center"> |
| 113 | <StxLink to="/dashboard" class="px-3.5 py-2 font-semibold text-ink text-sm border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] active:translate-y-px [transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease]">Dashboard</StxLink> | |
| 114 | <button @click="logout" class="px-3.5 py-2 font-semibold text-ink text-sm border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] active:translate-y-px [transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease]">Log out</button> | |
| 118 | {{-- This page has no server auth block: the viewer arrives from /api/me | |
| 119 | after paint, so these are bound rather than branched. Ungated, a | |
| 120 | signed-out visitor was offered "Log out", which logs nobody out, above | |
| 121 | a profile card of empty dashes. --}} | |
| 122 | <StxLink to="/dashboard" class="px-3.5 py-2 font-semibold text-ink text-sm border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] active:translate-y-px [transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease]" :show="signedIn()">Dashboard</StxLink> | |
| 123 | <button @click="logout" class="px-3.5 py-2 font-semibold text-ink text-sm border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] active:translate-y-px [transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease]" :show="signedIn()">Log out</button> | |
| 124 | <StxLink to="/login" class="px-3.5 py-2 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px] active:translate-y-px [transition:transform_0.12s_ease,opacity_0.15s_ease,background-color_0.15s_ease]" :show="signedOut()">Sign in</StxLink> | |
| 115 | 125 | </div> |
| 116 | 126 | </header> |
| 117 | 127 | |
| @@ -123,7 +133,16 @@ onMount(() => { | ||
| 123 | 133 | <div class="mx-auto px-5 py-8 md:px-6 max-w-2xl"> |
| 124 | 134 | <h1 class="mb-6 text-2xl wordmark">Account</h1> |
| 125 | 135 | |
| 126 | <section class="mb-5 p-6 bg-panel border border-line rounded-[14px]"> | |
| 136 | {{-- The signed-out resting state. Without it this page sat on a skeleton | |
| 137 | name, an empty email and a Free plan badge for a visitor who has no | |
| 138 | account loaded at all, which reads as a broken profile rather than as a | |
| 139 | page that needs signing in to. --}} | |
| 140 | <div class="py-24 text-center" :show="signedOut()"> | |
| 141 | <p class="mb-4 text-sm text-subtle">You're signed out.</p> | |
| 142 | <StxLink to="/login" class="inline-block px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">Sign in</StxLink> | |
| 143 | </div> | |
| 144 | ||
| 145 | <section class="mb-5 p-6 bg-panel border border-line rounded-[14px]" :show="!signedOut()"> | |
| 127 | 146 | <div class="flex gap-4 items-center"> |
| 128 | 147 | {{-- The initials block is the fallback for BOTH "no avatar" and "the |
| 129 | 148 | avatar failed to load", so the two :show conditions are exact |
| @@ -143,7 +162,7 @@ onMount(() => { | ||
| 143 | 162 | {{-- Skeleton and value are separate elements rather than one element |
| 144 | 163 | swapping classes: :class works but logs on every bind, and the |
| 145 | 164 | skeleton needs a fixed box the real text must not inherit. --}} |
| 146 | <div class="h-5 w-[160px] bg-[color-mix(in_srgb,var(--text-3)_20%,transparent)] rounded-md [animation:loghq-pulse_1.4s_ease-in-out_infinite] motion-reduce:[animation:none]" :show="!loaded()"></div> | |
| 165 | <div class="h-5 w-[160px] bg-[color-mix(in_srgb,var(--text-3)_20%,transparent)] rounded-md skeleton-pulse" :show="!loaded()"></div> | |
| 147 | 166 | <div class="font-semibold text-lg" :show="loaded()" :text="displayName()"></div> |
| 148 | 167 | <div class="mt-1.5 text-muted text-sm" :text="email()"></div> |
| 149 | 168 | </div> |
| @@ -151,7 +170,7 @@ onMount(() => { | ||
| 151 | 170 | </div> |
| 152 | 171 | </section> |
| 153 | 172 | |
| 154 | <section class="mb-5 bg-panel border border-line rounded-[14px]"> | |
| 173 | <section class="mb-5 bg-panel border border-line rounded-[14px]" :show="!signedOut()"> | |
| 155 | 174 | <div class="flex items-center justify-between px-6 py-4"> |
| 156 | 175 | <div> |
| 157 | 176 | <div class="font-mono text-subtle text-xs tracking-wide uppercase">Plan</div> |
| @@ -183,7 +202,7 @@ onMount(() => { | ||
| 183 | 202 | </div> |
| 184 | 203 | </section> |
| 185 | 204 | |
| 186 | <p class="text-sm text-subtle"> | |
| 205 | <p class="text-sm text-subtle" :show="!signedOut()"> | |
| 187 | 206 | Signed in with a private token. <StxLink to="/" class="text-accent">Back to site</StxLink> |
| 188 | 207 | </p> |
| 189 | 208 | </div> |
| @@ -240,141 +240,19 @@ onMounted(() => { | ||
| 240 | 240 | @section('content') |
| 241 | 241 | |
| 242 | 242 | |
| 243 | {{-- Date-range picker styling. A loose <style> under a layout: stx sweeps it | |
| 244 | into the page (loghq's default layout has no @stack('styles')). Self-contained | |
| 245 | to the picker, themed on the app's tokens. --}} | |
| 246 | <style> | |
| 247 | .range-tool { | |
| 248 | display: inline-flex; | |
| 249 | align-items: center; | |
| 250 | justify-content: center; | |
| 251 | gap: 0.4rem; | |
| 252 | min-height: 2.25rem; | |
| 253 | padding: 0 0.72rem; | |
| 254 | border: 1px solid var(--border); | |
| 255 | border-radius: 9px; | |
| 256 | background: var(--panel); | |
| 257 | color: var(--text-2); | |
| 258 | font: 600 0.8125rem/1 var(--sans, ui-sans-serif); | |
| 259 | text-decoration: none; | |
| 260 | cursor: pointer; | |
| 261 | transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; | |
| 262 | } | |
| 263 | .range-tool:hover { color: var(--text); border-color: var(--accent); } | |
| 264 | .range-tool:active { transform: scale(0.98); } | |
| 265 | .range-tool:focus-visible, | |
| 266 | .range-link:focus-visible, | |
| 267 | .chrome-btn:focus-visible, | |
| 268 | .icon-btn:focus-visible, | |
| 269 | select.panel:focus-visible, | |
| 270 | input:focus-visible, | |
| 271 | textarea:focus-visible, | |
| 272 | summary:focus-visible { | |
| 273 | outline: 2px solid var(--accent); | |
| 274 | outline-offset: 2px; | |
| 275 | } | |
| 276 | .range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 277 | .range-tool i { width: 1rem; height: 1rem; } | |
| 278 | .date-menu { position: relative; } | |
| 279 | .date-menu > summary { list-style: none; } | |
| 280 | .date-menu > summary::-webkit-details-marker { display: none; } | |
| 281 | .date-popover { | |
| 282 | position: absolute; | |
| 283 | right: 0; | |
| 284 | top: calc(100% + 0.5rem); | |
| 285 | z-index: 40; | |
| 286 | width: min(44rem, calc(100vw - 2.5rem)); | |
| 287 | padding: 0.75rem; | |
| 288 | border: 1px solid var(--border); | |
| 289 | border-radius: 12px; | |
| 290 | background: var(--panel); | |
| 291 | box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent); | |
| 292 | } | |
| 293 | /* Period picker: presets down the left, two months on the right. The range is | |
| 294 | drawn flat with rounded ends, the way Fathom's is, on the --bar wash the KPI | |
| 295 | bars already use so it reads as the same accent at the same strength. */ | |
| 296 | .drp-backdrop { position: fixed; inset: 0; z-index: 39; background: transparent; cursor: default; } | |
| 297 | .drp { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 0.75rem; } | |
| 298 | .drp-presets { display: flex; flex-direction: column; gap: 0.15rem; padding-right: 0.75rem; border-right: 1px solid var(--border); } | |
| 299 | .drp-preset { | |
| 300 | display: block; | |
| 301 | width: 100%; | |
| 302 | padding: 0.5rem 0.7rem; | |
| 303 | border: 0; | |
| 304 | border-radius: 8px; | |
| 305 | background: transparent; | |
| 306 | color: var(--text-2); | |
| 307 | font: 500 0.8125rem/1.2 var(--sans, ui-sans-serif); | |
| 308 | text-align: left; | |
| 309 | cursor: pointer; | |
| 310 | } | |
| 311 | .drp-preset:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); } | |
| 312 | .drp-preset.is-active { color: var(--accent-ink); background: var(--accent); } | |
| 313 | .drp-cals { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } | |
| 314 | .drp-head { display: grid; grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 2rem; align-items: center; gap: 0.35rem; margin-bottom: 0.5rem; } | |
| 315 | .drp-nav { | |
| 316 | display: inline-grid; | |
| 317 | place-items: center; | |
| 318 | width: 2rem; | |
| 319 | height: 2rem; | |
| 320 | padding: 0; | |
| 321 | border: 1px solid transparent; | |
| 322 | border-radius: 8px; | |
| 323 | background: transparent; | |
| 324 | color: var(--text-2); | |
| 325 | cursor: pointer; | |
| 326 | } | |
| 327 | .drp-nav:hover { color: var(--text); border-color: var(--border); } | |
| 328 | .drp-nav svg { width: 1rem; height: 1rem; } | |
| 329 | .drp-nav.is-hidden { visibility: hidden; } | |
| 330 | .drp-sel { | |
| 331 | min-width: 0; | |
| 332 | height: 2rem; | |
| 333 | padding: 0 0.5rem; | |
| 334 | border: 1px solid var(--border); | |
| 335 | border-radius: 8px; | |
| 336 | background: var(--bg); | |
| 337 | color: var(--text); | |
| 338 | font: 600 0.8125rem/1 var(--sans, ui-sans-serif); | |
| 339 | } | |
| 340 | .drp-dow, .drp-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } | |
| 341 | .drp-dow span { padding: 0.25rem 0; color: var(--text-3); font: 600 0.7rem/1.2 var(--sans, ui-sans-serif); text-align: center; } | |
| 342 | .drp-day { | |
| 343 | height: 2.25rem; | |
| 344 | padding: 0; | |
| 345 | border: 0; | |
| 346 | border-radius: 0; | |
| 347 | background: transparent; | |
| 348 | color: var(--text); | |
| 349 | font: 500 0.8125rem/1 var(--mono, ui-monospace); | |
| 350 | cursor: pointer; | |
| 351 | } | |
| 352 | .drp-day:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 353 | .drp-day.is-out { color: var(--text-3); } | |
| 354 | .drp-day.is-today { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; } | |
| 355 | .drp-day.is-range { background: var(--accent-soft); } | |
| 356 | .drp-day.is-edge { background: var(--accent); color: var(--accent-ink); border-radius: 8px; } | |
| 357 | .drp-day.is-disabled { color: var(--text-3); opacity: 0.45; cursor: default; } | |
| 358 | .drp-day.is-disabled:hover { background: transparent; } | |
| 359 | .drp-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; } | |
| 360 | @media (max-width: 1023px) { | |
| 361 | .date-menu { position: static; } | |
| 362 | .date-popover { left: 0; right: auto; width: calc(100vw - 2rem); max-height: calc(100dvh - 6rem); overflow-y: auto; overscroll-behavior: contain; } | |
| 363 | .drp { grid-template-columns: minmax(0, 1fr); } | |
| 364 | .drp-presets { flex-direction: row; flex-wrap: wrap; gap: 0.35rem; padding: 0 0 0.6rem; border-right: 0; border-bottom: 1px solid var(--border); } | |
| 365 | .drp-preset { width: auto; padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: 999px; } | |
| 366 | .drp-cals { grid-template-columns: minmax(0, 1fr); } | |
| 367 | .drp-cal.is-right { display: none; } | |
| 368 | .drp-cal.is-left .drp-nav.is-hidden { visibility: visible; } | |
| 369 | .drp-day { height: 2.5rem; } | |
| 370 | } | |
| 371 | </style> | |
| 372 | 243 | <div class="grain" aria-hidden="true"></div> |
| 373 | 244 | |
| 374 | 245 | <div class="px-5 py-10 md:px-10 min-h-[100dvh] page-dashboard"> |
| 375 | <StxLink class="chrome-upgrade" to="/pricing" :show="!pro()">Upgrade</StxLink> | |
| 376 | <span class="chrome-pro chrome-upgrade" :show="pro()">Pro</span> | |
| 377 | <button class="chrome-logout" @click="signOut">Log out</button> | |
| 246 | {{-- The whole chrome belongs to a signed-in viewer. Ungated, a signed-out | |
| 247 | visitor got a "Log out" button that logs nobody out and an "Upgrade" call | |
| 248 | to action, floating over the "You're signed out" panel below. `user` is | |
| 249 | resolved server-side from the auth-token cookie, so this is decided before | |
| 250 | paint and there is no flash of the wrong chrome. --}} | |
| 251 | @if (user) | |
| 252 | <StxLink class="chrome-upgrade" to="/pricing" :show="!pro()">Upgrade</StxLink> | |
| 253 | <span class="chrome-pro chrome-upgrade" :show="pro()">Pro</span> | |
| 254 | <button class="chrome-logout" @click="signOut">Log out</button> | |
| 255 | @endif | |
| 378 | 256 | |
| 379 | 257 | <div class="mx-auto max-w-5xl"> |
| 380 | 258 | |
| @@ -647,9 +647,16 @@ onMount(() => { | ||
| 647 | 647 | {{-- Fixed-position chrome. Inside the page container, not document.body, so an |
| 648 | 648 | SPA view swap takes it with the rest of the page. Position is fixed, so |
| 649 | 649 | where it sits in the DOM does not affect layout. --}} |
| 650 | <StxLink class="chrome-upgrade" to="/pricing" :show="!pro()">Upgrade</StxLink> | |
| 651 | <span class="chrome-pro chrome-upgrade" :show="pro()">Pro</span> | |
| 652 | <button class="chrome-logout" @click="signOut">Log out</button> | |
| 650 | {{-- The whole chrome belongs to a signed-in viewer. Ungated, a signed-out | |
| 651 | visitor got a "Log out" button that logs nobody out and an "Upgrade" call | |
| 652 | to action, floating over the "You're signed out" panel below. `user` is | |
| 653 | resolved server-side from the auth-token cookie, so this is decided before | |
| 654 | paint and there is no flash of the wrong chrome. --}} | |
| 655 | @if (user) | |
| 656 | <StxLink class="chrome-upgrade" to="/pricing" :show="!pro()">Upgrade</StxLink> | |
| 657 | <span class="chrome-pro chrome-upgrade" :show="pro()">Pro</span> | |
| 658 | <button class="chrome-logout" @click="signOut">Log out</button> | |
| 659 | @endif | |
| 653 | 660 | |
| 654 | 661 | <div class="mx-auto max-w-5xl"> |
| 655 | 662 | |
| @@ -674,7 +681,7 @@ onMount(() => { | ||
| 674 | 681 | @if (user && projectsUnavailable) |
| 675 | 682 | <div class="py-24 text-center"> |
| 676 | 683 | <p class="text-muted text-sm"><span class="font-semibold text-ink">Can't reach your projects right now.</span> Make sure Postgres is provisioned.</p> |
| 677 | <p class="mt-2 text-subtle text-xs">Nothing has been lost — reload once the database is back.</p> | |
| 684 | <p class="mt-2 text-subtle text-xs">Nothing has been lost. Reload once the database is back.</p> | |
| 678 | 685 | </div> |
| 679 | 686 | @endif |
| 680 | 687 | |
| @@ -720,17 +727,30 @@ onMount(() => { | ||
| 720 | 727 | <Icon name="lucide:moon" size="17" class="h-[17px] w-[17px] ic-moon" /> |
| 721 | 728 | </button> |
| 722 | 729 | {{-- x-tooltip lives on this WRAPPER, not on the StxLink, and that is |
| 723 | load-bearing. component-renderer.ts:154-159 rewrites any x-* attribute | |
| 724 | on a COMPONENT to a `:` signal binding unless the name is in its | |
| 725 | ALPINE_PASSTHROUGH set — and x-tooltip is not in that set, even though | |
| 726 | the signals runtime lists it in X_HANDLED (signals.ts:2295) as an | |
| 727 | attribute it consumes itself. So x-tooltip="Settings" on the StxLink | |
| 728 | became :tooltip="Settings", stx tried to evaluate the bare identifier | |
| 729 | Settings as an expression, and the page logged | |
| 730 | "hydration invariant failed after DOMContentLoaded: 1 expression(s) | |
| 731 | never evaluated — Settings is not defined". Filed upstream. The same | |
| 732 | trap applies to <Icon> and every other component; a plain element is | |
| 733 | the only safe host until the two lists agree. | |
| 730 | still load-bearing — but not for the reason this comment used to give, | |
| 731 | so here is the current state measured on the installed 0.2.532. | |
| 732 | ||
| 733 | WHAT WAS WRONG AND IS NOW FIXED. component-renderer rewrites any x-* | |
| 734 | attribute on a COMPONENT to a `:` signal binding unless the name is in | |
| 735 | its ALPINE_PASSTHROUGH set, and x-tooltip was not in that set even | |
| 736 | though the runtime consumed it itself. So x-tooltip="Settings" became | |
| 737 | :tooltip="Settings", stx evaluated the bare identifier, and the page | |
| 738 | logged "hydration invariant failed after DOMContentLoaded: 1 | |
| 739 | expression(s) never evaluated — Settings is not defined". The two | |
| 740 | lists agree now: ALPINE_PASSTHROUGH is built FROM | |
| 741 | RUNTIME_HANDLED_X_ATTRS, which lists x-tooltip, so no rewrite happens | |
| 742 | and the crash is gone. | |
| 743 | ||
| 744 | WHAT IS STILL WRONG, AND IS QUIETER. The attribute escapes the | |
| 745 | rewrite and then never reaches the element: processComponents emits it | |
| 746 | only inside data-stx-props, while the tooltip runtime reads | |
| 747 | el.getAttribute("x-tooltip"). So x-tooltip on a component now costs | |
| 748 | the tooltip instead of the page. title and aria-label both forward to | |
| 749 | the concrete root correctly (stx#2063); x-tooltip falls in the gap | |
| 750 | between the two. Filed as stacksjs/stx#2077. | |
| 751 | ||
| 752 | So the wrapper stays, and a plain element is still the only safe host | |
| 753 | — for <Icon> and every other component too. | |
| 734 | 754 | |
| 735 | 755 | The span is inline-flex so it behaves as the same flex item the |
| 736 | 756 | anchor used to be, and the hover area is unchanged. --}} |
| @@ -29,7 +29,7 @@ const crumbs = [ | ||
| 29 | 29 | <section class="feat-hero shell"> |
| 30 | 30 | <span class="kicker">Docs</span> |
| 31 | 31 | <h1>One HTTP request. Nothing to install.</h1> |
| 32 | <p>loghq ingests structured logs over plain JSON. If your language can make an HTTP request, it can ship logs — no SDK, no build step, no lock-in. Start here, then dive into the topic you need.</p> | |
| 32 | <p>loghq ingests structured logs over plain JSON. If your language can make an HTTP request, it can ship logs: no SDK, no build step, no lock-in. Start here, then dive into the topic you need.</p> | |
| 33 | 33 | <div class="hero-actions"> |
| 34 | 34 | <StxLink class="btn primary" to="/register">Create a project</StxLink> |
| 35 | 35 | <a class="btn ghost" href="https://github.com/stacksjs/loghq/blob/main/docs/ingest.md">Read the ingest spec</a> |
| @@ -46,7 +46,7 @@ const crumbs = [ | ||
| 46 | 46 | <span class="flow-num">01</span> |
| 47 | 47 | <div> |
| 48 | 48 | <h3>Copy your ingest key</h3> |
| 49 | <p>Every project has one, in project settings. It is public by design — safe to ship in a client bundle — revocable, rotatable, and grants no read access.</p> | |
| 49 | <p>Every project has one, in project settings. It is public by design, safe to ship in a client bundle, revocable, rotatable, and grants no read access.</p> | |
| 50 | 50 | </div> |
| 51 | 51 | </div> |
| 52 | 52 | <div class="flow-step"> |
| @@ -69,7 +69,7 @@ const crumbs = [ | ||
| 69 | 69 | <section class="reveal section shell" aria-label="Send your first log"> |
| 70 | 70 | <div class="max-w-[700px] code"> |
| 71 | 71 | <div class="code-bar"><span class="chart-cap">send your first log</span></div> |
| 72 | <pre class="code-body"><code># any language, any runtime — this is the whole integration | |
| 72 | <pre class="code-body"><code># any language, any runtime: this is the whole integration | |
| 73 | 73 | curl -X POST https://loghq.org/logs \ |
| 74 | 74 | -H 'Content-Type: application/json' \ |
| 75 | 75 | -H 'X-LogHQ-Key: loghq_your_project_key' \ |
| @@ -217,7 +217,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 217 | 217 | </section> |
| 218 | 218 | |
| 219 | 219 | @if (formError) |
| 220 | <div role="alert" class="mb-6 px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 220 | <div role="alert" class="mb-6 py-2 err-box notice-box">{{ formError }}</div> | |
| 221 | 221 | @endif |
| 222 | 222 | |
| 223 | 223 | @if (!enabled) |
| @@ -232,7 +232,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 232 | 232 | @endif |
| 233 | 233 | |
| 234 | 234 | @if (run && run.status === 'failed') |
| 235 | <div class="mb-6 px-3 py-3 text-sm rounded-lg err-box"> | |
| 235 | <div class="mb-6 py-3 err-box notice-box"> | |
| 236 | 236 | The last analysis stopped. {{ run.error || 'No further detail was recorded.' }} |
| 237 | 237 | </div> |
| 238 | 238 | @endif |
| @@ -53,7 +53,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 53 | 53 | </div> |
| 54 | 54 | |
| 55 | 55 | @if (sent) |
| 56 | <div role="status" class="px-3 py-3 text-sm rounded-lg ok-box"> | |
| 56 | <div role="status" class="py-3 notice-box ok-box"> | |
| 57 | 57 | If an account exists for that email, a reset link is on its way. It expires in 60 minutes. |
| 58 | 58 | </div> |
| 59 | 59 | <p class="mt-6 text-center text-sm text-subtle"> |
| @@ -63,7 +63,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 63 | 63 | {{-- Posts to itself; the action is in the server block. --}} |
| 64 | 64 | <form action="/forgot-password" method="POST" class="space-y-4"> |
| 65 | 65 | @if (formError) |
| 66 | <div class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 66 | <div class="py-2 err-box notice-box">{{ formError }}</div> | |
| 67 | 67 | @endif |
| 68 | 68 | |
| 69 | 69 | <p class="text-muted text-sm">Enter the email you signed up with and we will send you a link to reset your password.</p> |
| @@ -203,7 +203,7 @@ async function subscribe() { | ||
| 203 | 203 | required |
| 204 | 204 | x-model="email" |
| 205 | 205 | > |
| 206 | <button class="btn primary" type="submit" :disabled="subscribeMutation.loading()" :text="subscribeMutation.loading() ? 'Sending' : 'Keep me posted'">Keep me posted</button> | |
| 206 | <button class="btn primary" type="submit" :disabled="subscribeMutation.loading()" :aria-busy="subscribeMutation.loading()" :text="subscribeMutation.loading() ? 'Sending' : 'Keep me posted'">Keep me posted</button> | |
| 207 | 207 | </form> |
| 208 | 208 | <p class="sub-status" role="status" aria-live="polite" :data-state="failed() ? 'error' : ''" :text="status()"></p> |
| 209 | 209 | </div> |
| @@ -139,7 +139,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 139 | 139 | |
| 140 | 140 | {{-- The shared error box for both steps. `:text` overwrites the (empty) |
| 141 | 141 | static label after hydration and never cloaks. --}} |
| 142 | <div id="login-error" role="alert" :show="!!error()" :text="error()" class="mb-4 px-3 py-2 text-sm rounded-lg err-box"></div> | |
| 142 | <div id="login-error" role="alert" :show="!!error()" :text="error()" class="mb-4 py-2 err-box notice-box"></div> | |
| 143 | 143 | |
| 144 | 144 | {{-- Step 1: credentials. A same-origin fetch, not a native POST — the |
| 145 | 145 | submit is intercepted so the browser never navigates to /login itself. --}} |
| @@ -163,7 +163,7 @@ async function onVerify(e: Event): Promise<void> { | ||
| 163 | 163 | <span>Keep me signed in for 30 days</span> |
| 164 | 164 | </label> |
| 165 | 165 | |
| 166 | <button type="submit" :disabled="loading()" :text="loading() ? 'Signing in…' : 'Sign in'" class="py-2.5 w-full font-semibold text-sm btn-solid">Sign in</button> | |
| 166 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Signing in…' : 'Sign in'" className="auth-submit">Sign in</Button> | |
| 167 | 167 | </form> |
| 168 | 168 | |
| 169 | 169 | {{-- Step 2: TOTP, shown only when the account has 2FA enabled. --}} |
| @@ -173,11 +173,11 @@ async function onVerify(e: Event): Promise<void> { | ||
| 173 | 173 | <label for="login-code" class="block mb-1.5 font-medium text-muted text-xs">Authentication code</label> |
| 174 | 174 | <input id="login-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="123456" :model="code" class="px-3.5 py-2.5 w-full text-sm field" :aria-invalid="error() ? 'true' : 'false'" :aria-describedby="error() ? 'login-error' : ''"> |
| 175 | 175 | </div> |
| 176 | <button type="submit" :disabled="loading()" :text="loading() ? 'Verifying…' : 'Verify and sign in'" class="py-2.5 w-full font-semibold text-sm btn-solid">Verify and sign in</button> | |
| 176 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Verifying…' : 'Verify and sign in'" className="auth-submit">Verify and sign in</Button> | |
| 177 | 177 | </form> |
| 178 | 178 | |
| 179 | 179 | @if (oauthError) |
| 180 | <div role="alert" class="mt-4 px-3 py-2 text-sm rounded-lg err-box">{{ oauthError }}</div> | |
| 180 | <div role="alert" class="mt-4 py-2 err-box notice-box">{{ oauthError }}</div> | |
| 181 | 181 | @endif |
| 182 | 182 | |
| 183 | 183 | @if (anyOauth) |
| @@ -125,7 +125,7 @@ async function upgrade(): Promise<void> { | ||
| 125 | 125 | <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Full retention</li> |
| 126 | 126 | <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Priority support</li> |
| 127 | 127 | </ul> |
| 128 | <button @click="upgrade" :disabled="checkout.loading()" class="mt-7 py-2.5 w-full font-semibold text-accent-ink text-sm bg-accent rounded-[10px] disabled:opacity-60 active:translate-y-px disabled:cursor-default [transition:transform_0.12s_ease,opacity_0.15s_ease]" :text="checkout.loading() ? 'Redirecting…' : 'Upgrade to Pro'">Upgrade to Pro</button> | |
| 128 | <button @click="upgrade" :disabled="checkout.loading()" :aria-busy="checkout.loading()" class="mt-7 py-2.5 w-full font-semibold text-accent-ink text-sm bg-accent rounded-[10px] disabled:opacity-60 active:translate-y-px disabled:cursor-default [transition:transform_0.12s_ease,opacity_0.15s_ease]" :text="checkout.loading() ? 'Redirecting…' : 'Upgrade to Pro'">Upgrade to Pro</button> | |
| 129 | 129 | @if (err()) |
| 130 | 130 | <p class="mt-3 text-[#ef4444] text-center text-xs">{{ err() }}</p> |
| 131 | 131 | @endif |
| @@ -120,15 +120,28 @@ function logout(): void { | ||
| 120 | 120 | <header class="flex gap-4 items-center justify-between mb-9"> |
| 121 | 121 | <div> |
| 122 | 122 | <h1 class="leading-none text-2xl md:text-[28px] wordmark">Your apps</h1> |
| 123 | <p class="mt-2 text-sm text-subtle">Pick an app to track, or create a new one.</p> | |
| 123 | {{-- The subtitle invites an action, so it only belongs to someone who can | |
| 124 | take it. Signed out, the same line read "Pick an app to track, or | |
| 125 | create a new one." above a panel saying the visitor was signed out. --}} | |
| 126 | @if (user) | |
| 127 | <p class="mt-2 text-sm text-subtle">Pick an app to track, or create a new one.</p> | |
| 128 | @else | |
| 129 | <p class="mt-2 text-sm text-subtle">Sign in to see the apps you track.</p> | |
| 130 | @endif | |
| 124 | 131 | </div> |
| 125 | 132 | <div class="flex gap-2.5 items-center"> |
| 126 | 133 | <button class="inline-flex items-center justify-center h-9 w-9 text-subtle hover:text-ink bg-panel border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] cursor-pointer [transition:color_0.15s_ease,border-color_0.15s_ease] theme-btn" @click="toggleTheme" x-tooltip="Toggle theme" aria-label="Toggle theme"> |
| 127 | 134 | <Icon name="lucide:sun" size="18" class="h-[18px] w-[18px] ic-sun" /> |
| 128 | 135 | <Icon name="lucide:moon" size="18" class="h-[18px] w-[18px] ic-moon" /> |
| 129 | 136 | </button> |
| 130 | <StxLink to="/projects/new" class="px-4 py-2 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">+ New app</StxLink> | |
| 131 | <button @click="logout" class="px-3.5 py-2 font-medium text-ink text-sm bg-panel border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]">Log out</button> | |
| 137 | {{-- Both of these are signed-in affordances and neither was guarded, so a | |
| 138 | signed-out visitor was offered "+ New app", which bounces straight back | |
| 139 | to the login wall, and "Log out", which logs out nobody. The theme | |
| 140 | toggle above stays: it works for anyone. --}} | |
| 141 | @if (user) | |
| 142 | <StxLink to="/projects/new" class="px-4 py-2 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px]">+ New app</StxLink> | |
| 143 | <button @click="logout" class="px-3.5 py-2 font-medium text-ink text-sm bg-panel border border-line rounded-[10px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))]">Log out</button> | |
| 144 | @endif | |
| 132 | 145 | </div> |
| 133 | 146 | </header> |
| 134 | 147 | |
| @@ -164,7 +177,7 @@ function logout(): void { | ||
| 164 | 177 | @if (projectsUnavailable) |
| 165 | 178 | <div class="px-5 py-16 text-center bg-panel border border-line rounded-xl"> |
| 166 | 179 | <div class="mb-1 font-medium text-base">Can't reach your apps right now</div> |
| 167 | <div class="text-sm text-subtle">Make sure Postgres is provisioned. Nothing has been lost — reload once the database is back.</div> | |
| 180 | <div class="text-sm text-subtle">Make sure Postgres is provisioned. Nothing has been lost. Reload once the database is back.</div> | |
| 168 | 181 | </div> |
| 169 | 182 | @else |
| 170 | 183 | <EmptyState title="No apps yet" description="Create your first app to start capturing errors."> |
| @@ -106,7 +106,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri | ||
| 106 | 106 | {{-- Posts to itself; the action is in the server block. --}} |
| 107 | 107 | <form action="/projects/new" method="POST" class="space-y-4"> |
| 108 | 108 | @if (formError) |
| 109 | <div id="form-error" role="alert" class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 109 | <div id="form-error" role="alert" class="py-2 err-box notice-box">{{ formError }}</div> | |
| 110 | 110 | @endif |
| 111 | 111 | |
| 112 | 112 | <div> |
| @@ -92,10 +92,10 @@ async function onRegister(e: Event): Promise<void> { | ||
| 92 | 92 | </div> |
| 93 | 93 | |
| 94 | 94 | @if (isInvite) |
| 95 | <p class="mb-3.5 invite-hint">You've been invited to a project — sign up to join it.</p> | |
| 95 | <p class="mb-3.5 invite-hint">You've been invited to a project. Sign up to join it.</p> | |
| 96 | 96 | @endif |
| 97 | 97 | |
| 98 | <div id="form-error" role="alert" :show="!!error()" :text="error()" class="mb-4 px-3 py-2 text-sm rounded-lg err-box"></div> | |
| 98 | <div id="form-error" role="alert" :show="!!error()" :text="error()" class="mb-4 py-2 err-box notice-box"></div> | |
| 99 | 99 | |
| 100 | 100 | {{-- A same-origin fetch, not a native POST — the submit is intercepted. --}} |
| 101 | 101 | <form action="/api/auth/register" method="POST" @submit="onRegister" class="space-y-4"> |
| @@ -112,7 +112,7 @@ async function onRegister(e: Event): Promise<void> { | ||
| 112 | 112 | <input id="register-password" name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" :model="password" class="px-3.5 py-2.5 w-full text-sm field" :aria-invalid="error() ? 'true' : 'false'" :aria-describedby="error() ? 'form-error' : ''"> |
| 113 | 113 | </div> |
| 114 | 114 | |
| 115 | <button type="submit" :disabled="loading()" :text="loading() ? 'Creating account…' : 'Create account'" class="py-2.5 w-full font-semibold text-sm btn-solid">Create account</button> | |
| 115 | <Button type="submit" size="sm" fullWidth :loading="loading()" :label="loading() ? 'Creating account…' : 'Create account'" className="auth-submit">Create account</Button> | |
| 116 | 116 | </form> |
| 117 | 117 | |
| 118 | 118 | @if (anyOauth) |
| @@ -72,7 +72,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 72 | 72 | </div> |
| 73 | 73 | |
| 74 | 74 | @if (done) |
| 75 | <div class="px-3 py-3 text-sm rounded-lg ok-box"> | |
| 75 | <div class="py-3 notice-box ok-box"> | |
| 76 | 76 | Your password has been reset. You have been signed out everywhere; sign in with the new password. |
| 77 | 77 | </div> |
| 78 | 78 | <p class="mt-6 text-center text-sm text-subtle"> |
| @@ -85,7 +85,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) => | ||
| 85 | 85 | <input type="hidden" name="token" value=""> |
| 86 | 86 | <input type="hidden" name="email" value=""> |
| 87 | 87 | @if (formError) |
| 88 | <div id="form-error" role="alert" class="px-3 py-2 text-sm rounded-lg err-box">{{ formError }}</div> | |
| 88 | <div id="form-error" role="alert" class="py-2 err-box notice-box">{{ formError }}</div> | |
| 89 | 89 | @endif |
| 90 | 90 | |
| 91 | 91 | <div> |
| @@ -116,7 +116,7 @@ if (user) { | ||
| 116 | 116 | try { |
| 117 | 117 | // Owned projects only — settings is the owner's admin surface. |
| 118 | 118 | __projectRows = (await __db.unsafe( |
| 119 | 'SELECT id, name, platform, ingest_key, is_active FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id', | |
| 119 | 'SELECT id, name, platform, ingest_key, is_active, read_key, read_key_last_used_at FROM projects WHERE owner_id = $1 ORDER BY created_at DESC NULLS LAST, id', | |
| 120 | 120 | [Number(user.id)], |
| 121 | 121 | )) ?? [] |
| 122 | 122 | if (__projectRows.length === 0) |
| @@ -158,8 +158,8 @@ if (user) { | ||
| 158 | 158 | |
| 159 | 159 | // Template-facing projections. `ingest_key` is dropped here and nowhere else: |
| 160 | 160 | // the switcher needs a project's id, name and state, never its credential. |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, ...p }) => p)(__activeRow) : null | |
| 161 | const projects = __projectRows.map(({ ingest_key: _ignored, read_key: _secret, ...p }) => p) | |
| 162 | const activeProject = __activeRow ? (({ ingest_key: _ignored, read_key: _secret, ...p }) => p)(__activeRow) : null | |
| 163 | 163 | const projectId = activeProject?.id || null |
| 164 | 164 | |
| 165 | 165 | // The connected repository, for the Repository section below. Loaded here |
| @@ -253,6 +253,12 @@ const inviteView = __inviteRows.map(i => ({ id: i.id, email: i.email, registered | ||
| 253 | 253 | // named value, so publishing it stays a decision rather than a side effect. |
| 254 | 254 | const keyFull = __activeRow ? String(__activeRow.ingest_key || '') : '' |
| 255 | 255 | const keyMask = maskKey(keyFull) |
| 256 | // The read key itself never reaches the page: it is a secret, shown once when | |
| 257 | // it is issued and never again (#14). Only whether one exists, and when it was | |
| 258 | // last used, so somebody can tell a live key from a forgotten one before | |
| 259 | // revoking it. | |
| 260 | const hasReadKeyInitial = !!(__activeRow && __activeRow.read_key) | |
| 261 | const readKeyUsedInitial = __activeRow && __activeRow.read_key_last_used_at ? String(__activeRow.read_key_last_used_at) : '' | |
| 256 | 262 | |
| 257 | 263 | // Framework-specific install snippet, keyed off the project's platform. Key-only |
| 258 | 264 | // (Flare-style): the ingest key alone identifies the project — no project id, no DSN. |
| @@ -539,6 +545,8 @@ defineClientPayload({ | ||
| 539 | 545 | codeText: guide.code, |
| 540 | 546 | initialKeyFull: keyFull, |
| 541 | 547 | initialKeyMask: keyMask, |
| 548 | hasReadKeyInitial, | |
| 549 | readKeyUsedInitial, | |
| 542 | 550 | }) |
| 543 | 551 | |
| 544 | 552 | useSeoMeta({ |
| @@ -714,6 +722,14 @@ const repo = state<any>(repository || null) | ||
| 714 | 722 | const repoRef = state('') |
| 715 | 723 | const repoToken = state('') |
| 716 | 724 | const repoBusy = state(false) |
| 725 | /* | |
| 726 | * Which repository button is working. `repoBusy` gates all three, which is right | |
| 727 | * for :disabled -- none of them should be clickable while any runs -- and wrong | |
| 728 | * for aria-busy, which names ONE element as updating. Verify and Disconnect sit | |
| 729 | * in the same `:show="!!repo()"` block, so with the shared signal on both, | |
| 730 | * verifying would announce Disconnect as busy too. | |
| 731 | */ | |
| 732 | const repoAction = state('') | |
| 717 | 733 | const repoNote = state('') |
| 718 | 734 | const repoNoteOk = state(true) |
| 719 | 735 | |
| @@ -725,10 +741,12 @@ async function connectRepo(): Promise<void> { | ||
| 725 | 741 | return |
| 726 | 742 | } |
| 727 | 743 | repoBusy.set(true) |
| 744 | repoAction.set('connect') | |
| 728 | 745 | try { |
| 729 | 746 | const res = await apiFetch(`${projUrl}/repository`, { method: 'POST', body: JSON.stringify({ repository: ref, token }) }) |
| 730 | 747 | const body = await readJson(res) |
| 731 | 748 | repoBusy.set(false) |
| 749 | repoAction.set('') | |
| 732 | 750 | if (!res.ok) { |
| 733 | 751 | note(repoNote, repoNoteOk, body.error || 'Could not connect.', false) |
| 734 | 752 | return |
| @@ -741,16 +759,19 @@ async function connectRepo(): Promise<void> { | ||
| 741 | 759 | } |
| 742 | 760 | catch { |
| 743 | 761 | repoBusy.set(false) |
| 762 | repoAction.set('') | |
| 744 | 763 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 745 | 764 | } |
| 746 | 765 | } |
| 747 | 766 | |
| 748 | 767 | async function verifyRepo(): Promise<void> { |
| 749 | 768 | repoBusy.set(true) |
| 769 | repoAction.set('verify') | |
| 750 | 770 | try { |
| 751 | 771 | const res = await apiFetch(`${projUrl}/repository/verify`, { method: 'POST' }) |
| 752 | 772 | const body = await readJson(res) |
| 753 | 773 | repoBusy.set(false) |
| 774 | repoAction.set('') | |
| 754 | 775 | if (!res.ok) { |
| 755 | 776 | note(repoNote, repoNoteOk, body.error || 'Verification failed.', false) |
| 756 | 777 | return |
| @@ -760,6 +781,7 @@ async function verifyRepo(): Promise<void> { | ||
| 760 | 781 | } |
| 761 | 782 | catch { |
| 762 | 783 | repoBusy.set(false) |
| 784 | repoAction.set('') | |
| 763 | 785 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 764 | 786 | } |
| 765 | 787 | } |
| @@ -771,9 +793,11 @@ async function disconnectRepo(): Promise<void> { | ||
| 771 | 793 | if (!(await Promise.resolve(ask))) |
| 772 | 794 | return |
| 773 | 795 | repoBusy.set(true) |
| 796 | repoAction.set('disconnect') | |
| 774 | 797 | try { |
| 775 | 798 | const res = await apiFetch(`${projUrl}/repository`, { method: 'DELETE' }) |
| 776 | 799 | repoBusy.set(false) |
| 800 | repoAction.set('') | |
| 777 | 801 | if (!res.ok) { |
| 778 | 802 | const body = await readJson(res) |
| 779 | 803 | note(repoNote, repoNoteOk, body.error || 'Could not disconnect.', false) |
| @@ -784,6 +808,7 @@ async function disconnectRepo(): Promise<void> { | ||
| 784 | 808 | } |
| 785 | 809 | catch { |
| 786 | 810 | repoBusy.set(false) |
| 811 | repoAction.set('') | |
| 787 | 812 | note(repoNote, repoNoteOk, 'Network error. Try again.', false) |
| 788 | 813 | } |
| 789 | 814 | } |
| @@ -828,6 +853,88 @@ async function rotateKey(): Promise<void> { | ||
| 828 | 853 | } |
| 829 | 854 | } |
| 830 | 855 | |
| 856 | // ---- read key ---- | |
| 857 | // Issued on demand, shown once, and revocable (#14). The key is held in a | |
| 858 | // signal only for as long as the page that issued it is open: it is never | |
| 859 | // rendered from the server and nothing stores it, so closing the page is the | |
| 860 | // end of the only copy the browser ever had. | |
| 861 | const hasReadKey = state(hasReadKeyInitial) | |
| 862 | const readKeyUsed = state(readKeyUsedInitial) | |
| 863 | const readKeyOnce = state('') | |
| 864 | const readKeyBusy = state(false) | |
| 865 | const readKeyNote = state('') | |
| 866 | const readKeyNoteOk = state(true) | |
| 867 | const copiedReadKey = state(false) | |
| 868 | ||
| 869 | /** "never", or the stamp as the browser's locale writes it. */ | |
| 870 | const readKeyUsedLabel = (): string => { | |
| 871 | const at = readKeyUsed() | |
| 872 | if (!at) | |
| 873 | return 'never used' | |
| 874 | const when = new Date(at) | |
| 875 | return Number.isNaN(when.getTime()) ? 'used recently' : `last used ${when.toLocaleString()}` | |
| 876 | } | |
| 877 | ||
| 878 | function copyReadKey(): void { | |
| 879 | void copyText(readKeyOnce(), copiedReadKey) | |
| 880 | } | |
| 881 | ||
| 882 | async function issueReadKey(): Promise<void> { | |
| 883 | // Replacing one is destructive in the same way rotating the ingest key is, | |
| 884 | // so it asks — but only when there is something to break. | |
| 885 | if (hasReadKey()) { | |
| 886 | const ask = window.stxConfirm | |
| 887 | ? window.stxConfirm('The current read key stops working immediately, and anything still using it will get 404 until you update it. Continue?', { title: 'Replace read key?', confirmText: 'Replace key', cancelText: 'Cancel', type: 'warning' }) | |
| 888 | : Promise.resolve(true) | |
| 889 | if (!(await Promise.resolve(ask))) | |
| 890 | return | |
| 891 | } | |
| 892 | readKeyBusy.set(true) | |
| 893 | try { | |
| 894 | const res = await apiFetch(`${projUrl}/read-key`, { method: 'POST' }) | |
| 895 | const body = await readJson(res) | |
| 896 | readKeyBusy.set(false) | |
| 897 | if (!res.ok) { | |
| 898 | note(readKeyNote, readKeyNoteOk, body.error || 'Could not issue a key.', false) | |
| 899 | return | |
| 900 | } | |
| 901 | readKeyOnce.set(String(body.read_key || '')) | |
| 902 | hasReadKey.set(true) | |
| 903 | readKeyUsed.set('') | |
| 904 | note(readKeyNote, readKeyNoteOk, 'Copy it now. This is the only time it is shown.', true) | |
| 905 | } | |
| 906 | catch { | |
| 907 | readKeyBusy.set(false) | |
| 908 | note(readKeyNote, readKeyNoteOk, 'Network error.', false) | |
| 909 | } | |
| 910 | } | |
| 911 | ||
| 912 | async function revokeReadKey(): Promise<void> { | |
| 913 | const ask = window.stxConfirm | |
| 914 | ? window.stxConfirm('Anything using this key stops reading immediately. Continue?', { title: 'Revoke read key?', confirmText: 'Revoke', cancelText: 'Cancel', type: 'warning' }) | |
| 915 | : Promise.resolve(true) | |
| 916 | if (!(await Promise.resolve(ask))) | |
| 917 | return | |
| 918 | readKeyBusy.set(true) | |
| 919 | try { | |
| 920 | const res = await apiFetch(`${projUrl}/read-key`, { method: 'DELETE' }) | |
| 921 | const body = await readJson(res) | |
| 922 | readKeyBusy.set(false) | |
| 923 | if (!res.ok) { | |
| 924 | note(readKeyNote, readKeyNoteOk, body.error || 'Could not revoke.', false) | |
| 925 | return | |
| 926 | } | |
| 927 | hasReadKey.set(false) | |
| 928 | readKeyUsed.set('') | |
| 929 | readKeyOnce.set('') | |
| 930 | note(readKeyNote, readKeyNoteOk, 'Revoked.', true) | |
| 931 | } | |
| 932 | catch { | |
| 933 | readKeyBusy.set(false) | |
| 934 | note(readKeyNote, readKeyNoteOk, 'Network error.', false) | |
| 935 | } | |
| 936 | } | |
| 937 | ||
| 831 | 938 | // ---- members and invites ---- |
| 832 | 939 | const inviteEmail = state('') |
| 833 | 940 | const inviting = state(false) |
| @@ -1225,7 +1332,7 @@ onMount(() => { | ||
| 1225 | 1332 | @if (user && projectsUnavailable) |
| 1226 | 1333 | <div class="py-24 text-center"> |
| 1227 | 1334 | <p class="text-muted text-sm"><span class="font-semibold text-ink">Can't reach your settings right now.</span> Make sure Postgres is provisioned.</p> |
| 1228 | <p class="mt-2 text-subtle text-xs">Nothing has been changed — reload once the database is back.</p> | |
| 1335 | <p class="mt-2 text-subtle text-xs">Nothing has been changed. Reload once the database is back.</p> | |
| 1229 | 1336 | </div> |
| 1230 | 1337 | @endif |
| 1231 | 1338 | |
| @@ -1254,6 +1361,7 @@ onMount(() => { | ||
| 1254 | 1361 | <nav class="flex flex-wrap gap-2 items-center mb-8 text-sm"> |
| 1255 | 1362 | <a href="#install" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Install</a> |
| 1256 | 1363 | <a href="#keys" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Keys</a> |
| 1364 | <a href="#read-key" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Read key</a> | |
| 1257 | 1365 | <a href="#repository" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Repository</a> |
| 1258 | 1366 | <a href="#members" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Members</a> |
| 1259 | 1367 | <a href="#alerts" class="px-3 py-1.5 font-medium text-muted hover:text-ink border border-line rounded-[999px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Alerts</a> |
| @@ -1276,12 +1384,12 @@ onMount(() => { | ||
| 1276 | 1384 | @if (guide.install) |
| 1277 | 1385 | <div class="relative mb-2"> |
| 1278 | 1386 | <div class="overflow-x-auto pl-4 pr-16 py-3 font-mono leading-[1.5] text-[12px] text-muted bg-canvas border border-line rounded-[10px]">{{ guide.install }}</div> |
| 1279 | <button type="button" @click="copyInstall" class="absolute right-2 top-2 px-[9px] py-[5px] font-medium font-sans leading-none text-[11px] text-subtle hover:text-ink bg-panel border border-line rounded-[7px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] cursor-pointer [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedInstall() ? 'Copied' : 'Copy'">Copy</button> | |
| 1387 | <button type="button" @click="copyInstall" class="btn-copy" :text="copiedInstall() ? 'Copied' : 'Copy'">Copy</button> | |
| 1280 | 1388 | </div> |
| 1281 | 1389 | @endif |
| 1282 | 1390 | <div class="relative"> |
| 1283 | 1391 | <div class="overflow-x-auto pl-4 pr-16 py-3 font-mono leading-[1.5] text-[12px] text-muted whitespace-pre bg-canvas border border-line rounded-[10px]">{{ guide.code }}</div> |
| 1284 | <button type="button" @click="copyCode" class="absolute right-2 top-2 px-[9px] py-[5px] font-medium font-sans leading-none text-[11px] text-subtle hover:text-ink bg-panel border border-line rounded-[7px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] cursor-pointer [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedCode() ? 'Copied' : 'Copy'">Copy</button> | |
| 1392 | <button type="button" @click="copyCode" class="btn-copy" :text="copiedCode() ? 'Copied' : 'Copy'">Copy</button> | |
| 1285 | 1393 | </div> |
| 1286 | 1394 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| 1287 | 1395 | @if (guide.native) |
| @@ -1301,21 +1409,60 @@ onMount(() => { | ||
| 1301 | 1409 | <div class="px-5 py-5 app-panel"> |
| 1302 | 1410 | <p class="mb-4 text-sm text-subtle"> |
| 1303 | 1411 | This key authorizes log ingest for <span class="font-mono text-muted">{{ activeProject?.name }}</span>. |
| 1304 | It's a public identifier (safe to ship in client code), not a secret — it says <em>which</em> project an entry belongs to, and grants no read access. | |
| 1412 | It's a public identifier (safe to ship in client code), not a secret: it says <em>which</em> project an entry belongs to, and grants no read access. | |
| 1305 | 1413 | </p> |
| 1306 | 1414 | <div class="flex flex-wrap gap-2 items-center"> |
| 1307 | 1415 | <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm tracking-[0.01em] truncate bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" :text="keyShown()">{{ keyMask }}</code> |
| 1308 | <button @click="keyRevealed.set(!keyRevealed())" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="keyRevealed() ? 'Hide' : 'Reveal'">Reveal</button> | |
| 1309 | <button @click="copyKey" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" :text="copiedKey() ? 'Copied' : 'Copy'">Copy</button> | |
| 1310 | <button @click="rotateKey" :disabled="rotating()" class="px-3 py-2 font-medium text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Rotate</button> | |
| 1416 | <button @click="keyRevealed.set(!keyRevealed())" class="btn-quiet btn-sm" :text="keyRevealed() ? 'Hide' : 'Reveal'">Reveal</button> | |
| 1417 | <button @click="copyKey" class="btn-quiet btn-sm" :text="copiedKey() ? 'Copied' : 'Copy'">Copy</button> | |
| 1418 | <Button variant="ghost" @click="rotateKey" :loading="rotating()" className="btn-danger btn-sm">Rotate</Button> | |
| 1311 | 1419 | </div> |
| 1312 | 1420 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| 1313 | Rotating generates a new key and <span class="text-warn">invalidates the current one immediately</span> — anything still sending the old key will get 401 until you update it (<span class="font-mono">X-LogHQ-Key</span>). | |
| 1421 | Rotating generates a new key and <span class="text-warn">invalidates the current one immediately</span>. Anything still sending the old key will get 401 until you update it (<span class="font-mono">X-LogHQ-Key</span>). | |
| 1314 | 1422 | </p> |
| 1315 | 1423 | <span class="mt-2 text-ok text-xs" :show="!!keyNote()" :class="keyNoteOk() ? 'mt-2 text-xs text-ok' : 'mt-2 text-xs text-accent'">{{ keyNote() }}</span> |
| 1316 | 1424 | </div> |
| 1317 | 1425 | </section> |
| 1318 | 1426 | |
| 1427 | {{-- ========================= READ KEY ========================= --}} | |
| 1428 | <section id="read-key" class="mb-10 scroll-mt-[84px]"> | |
| 1429 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Read key</div> | |
| 1430 | <div class="px-5 py-5 app-panel"> | |
| 1431 | <p class="mb-4 text-sm text-subtle"> | |
| 1432 | Lets a server read this project's logs without signing in as you: a Stacks dashboard, or anything else reading the API machine-to-machine. | |
| 1433 | Unlike the API key above this one <em>is</em> a secret: it reads your logs, it belongs to no account, and it keeps working after you leave. | |
| 1434 | Send it as <span class="font-mono">Authorization: Bearer</span>. | |
| 1435 | </p> | |
| 1436 | ||
| 1437 | {{-- Just issued: the only time the key is ever shown. --}} | |
| 1438 | <div :show="!!readKeyOnce()"> | |
| 1439 | <div class="flex flex-wrap gap-2 items-center"> | |
| 1440 | <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm tracking-[0.01em] truncate bg-canvas border border-line rounded-[10px]" :text="readKeyOnce()"></code> | |
| 1441 | <button @click="copyReadKey" class="btn-quiet btn-sm" :text="copiedReadKey() ? 'Copied' : 'Copy'">Copy</button> | |
| 1442 | </div> | |
| 1443 | <p class="mt-3.5 leading-relaxed text-warn text-xs"> | |
| 1444 | Copy it now. It is not stored anywhere you can read it back, so leaving this page means issuing a new one. | |
| 1445 | </p> | |
| 1446 | </div> | |
| 1447 | ||
| 1448 | {{-- Otherwise: whether one exists, and whether it is still in use. --}} | |
| 1449 | <div :show="!readKeyOnce()"> | |
| 1450 | <div class="flex flex-wrap gap-2 items-center"> | |
| 1451 | <span class="flex-1 min-w-[220px] text-sm" :class="hasReadKey() ? 'text-ink' : 'text-subtle'" :text="hasReadKey() ? `A read key is active, ${readKeyUsedLabel()}.` : 'No read key. Issue one to let a server read this project.'"></span> | |
| 1452 | <Button variant="ghost" @click="issueReadKey" :loading="readKeyBusy()" :label="hasReadKey() ? 'Replace' : 'Issue key'" className="btn-quiet btn-sm">Issue key</Button> | |
| 1453 | <span :show="hasReadKey()"> | |
| 1454 | <Button variant="ghost" @click="revokeReadKey" :loading="readKeyBusy()" className="btn-danger btn-sm">Revoke</Button> | |
| 1455 | </span> | |
| 1456 | </div> | |
| 1457 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> | |
| 1458 | The last-used time is written at most once a minute, so a key in constant use shows a time up to a minute old. | |
| 1459 | </p> | |
| 1460 | </div> | |
| 1461 | ||
| 1462 | <span class="mt-2 text-ok text-xs" :show="!!readKeyNote()" :class="readKeyNoteOk() ? 'mt-2 text-xs text-ok' : 'mt-2 text-xs text-accent'">{{ readKeyNote() }}</span> | |
| 1463 | </div> | |
| 1464 | </section> | |
| 1465 | ||
| 1319 | 1466 | {{-- ========================== REPOSITORY ========================== --}} |
| 1320 | 1467 | <section id="repository" class="mb-10 scroll-mt-[84px]"> |
| 1321 | 1468 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Repository</div> |
| @@ -1330,8 +1477,8 @@ onMount(() => { | ||
| 1330 | 1477 | <div :show="!!repo()"> |
| 1331 | 1478 | <div class="flex flex-wrap gap-2 items-center mb-3.5"> |
| 1332 | 1479 | <code class="flex-1 px-3.5 py-2.5 min-w-[220px] font-mono text-ink text-sm truncate bg-canvas border border-line rounded-[10px]" :text="repo()?.full_name">.</code> |
| 1333 | <button @click="verifyRepo" :disabled="repoBusy()" class="px-3 py-2 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Verify</button> | |
| 1334 | <button @click="disconnectRepo" :disabled="repoBusy()" class="px-3 py-2 font-medium text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Disconnect</button> | |
| 1480 | <button @click="verifyRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'verify'" class="btn-quiet btn-sm">Verify</button> | |
| 1481 | <button @click="disconnectRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'disconnect'" class="btn-danger btn-sm">Disconnect</button> | |
| 1335 | 1482 | </div> |
| 1336 | 1483 | <dl class="grid gap-y-1.5 grid-cols-2 sm:grid-cols-3 text-xs"> |
| 1337 | 1484 | <div><dt class="text-subtle">Default branch</dt><dd class="font-mono text-muted" :text="repo()?.default_branch || 'unknown'">.</dd></div> |
| @@ -1348,7 +1495,7 @@ onMount(() => { | ||
| 1348 | 1495 | <label class="block mb-1.5 font-medium text-muted text-xs" for="repo-token">GitHub token</label> |
| 1349 | 1496 | <input id="repo-token" :model="repoToken" type="password" autocomplete="off" placeholder="github_pat_..." class="mb-3 px-3.5 py-2.5 w-full font-mono text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none"> |
| 1350 | 1497 | |
| 1351 | <button @click="connectRepo" :disabled="repoBusy()" class="px-3.5 py-2.5 font-semibold text-sm btn-solid">Connect repository</button> | |
| 1498 | <button @click="connectRepo" :disabled="repoBusy()" :aria-busy="repoAction() === 'connect'" class="btn-cta btn-solid">Connect repository</button> | |
| 1352 | 1499 | |
| 1353 | 1500 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| 1354 | 1501 | Use a <span class="text-muted">fine-grained</span> personal access token scoped to this one |
| @@ -1384,7 +1531,31 @@ onMount(() => { | ||
| 1384 | 1531 | <div class="mt-0.5 text-subtle text-xs">Can read the log stream</div> |
| 1385 | 1532 | </div> |
| 1386 | 1533 | <span class="px-[9px] py-0.5 font-semibold text-[11px] text-accent tracking-[0.02em] bg-[color-mix(in_srgb,var(--accent)_14%,var(--panel))] rounded-[999px]">Member</span> |
| 1387 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Remove">Remove</button> | |
| 1534 | {{-- STILL A PLAIN BUTTON, and the reason is x-tooltip. | |
| 1535 | ||
| 1536 | This is one of four buttons in the file that hand-roll busy | |
| 1537 | state and would otherwise take <Button> like the five that | |
| 1538 | did: these two Removes, the channel Test and the channel | |
| 1539 | Remove. An `x-tooltip` on a COMPONENT never reaches the DOM, | |
| 1540 | so adopting here would delete the tooltip without any sign. | |
| 1541 | ||
| 1542 | Measured on the installed 0.2.532, not read off a changelog. | |
| 1543 | `x-tooltip` IS in COMPONENT_PASSTHROUGH_X_ATTRS now, so it is | |
| 1544 | no longer rewritten into a `:tooltip` signal binding -- that | |
| 1545 | is the crash dashboard.stx documents, and it is fixed. What | |
| 1546 | remains is quieter: processComponents emits the attribute only | |
| 1547 | inside `data-stx-props`, while the tooltip runtime reads | |
| 1548 | `el.getAttribute('x-tooltip')`. `title` and `aria-label` both | |
| 1549 | forward to the concrete root correctly (stx#2063); | |
| 1550 | `x-tooltip` falls in the gap between the two. | |
| 1551 | ||
| 1552 | `title` instead would work and would be a different tooltip -- | |
| 1553 | the browser's, not the app's. Not worth trading a consistent | |
| 1554 | surface for a spinner. Filed as stacksjs/stx#2077; revisit | |
| 1555 | when it lands. | |
| 1556 | tests/unit/button-vocabulary.test.ts fails if a <Button> here | |
| 1557 | ever gets an x-tooltip. --}} | |
| 1558 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" :aria-busy="removingMember() === ''" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button> | |
| 1388 | 1559 | </div> |
| 1389 | 1560 | @endforeach |
| 1390 | 1561 | @foreach (inviteView as i) |
| @@ -1392,11 +1563,11 @@ onMount(() => { | ||
| 1392 | 1563 | <span class="grid place-items-center shrink-0 h-8 w-8 font-semibold text-subtle bg-[color-mix(in_srgb,var(--text-3)_12%,var(--panel))] rounded-lg">{{ (i.email || '?').charAt(0).toUpperCase() }}</span> |
| 1393 | 1564 | <div class="flex-1 min-w-0"> |
| 1394 | 1565 | <div class="font-medium text-sm truncate">{{ i.email }}</div> |
| 1395 | <div class="mt-0.5 text-subtle text-xs">{{ i.registered ? 'Has an account — pending accept' : 'Invite sent — awaiting sign-up' }}</div> | |
| 1566 | <div class="mt-0.5 text-subtle text-xs">{{ i.registered ? 'Has an account, pending accept' : 'Invite sent, awaiting sign-up' }}</div> | |
| 1396 | 1567 | </div> |
| 1397 | <span class="px-[9px] py-0.5 font-semibold text-[11px] text-muted tracking-[0.02em] bg-[color-mix(in_srgb,var(--text-3)_18%,var(--panel))] rounded-[999px]" x-tooltip="Pending — becomes a member once they accept">Invited</span> | |
| 1398 | <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="px-2.5 py-1.5 font-medium text-muted text-xs hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" x-tooltip="Copy join link">Copy link</button> | |
| 1399 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Revoke invite">Remove</button> | |
| 1568 | <span class="px-[9px] py-0.5 font-semibold text-[11px] text-muted tracking-[0.02em] bg-[color-mix(in_srgb,var(--text-3)_18%,var(--panel))] rounded-[999px]" x-tooltip="Pending: becomes a member once they accept">Invited</span> | |
| 1569 | <button @click="copyJoin('', '')" :text="joinBtnLabel('')" class="btn-quiet btn-xs" x-tooltip="Copy join link">Copy link</button> | |
| 1570 | <button @click="removeMember('')" :disabled="removingMember() === ''" :text="memberBtnLabel('')" :aria-busy="removingMember() === ''" class="btn-danger btn-xs" x-tooltip="Revoke invite">Remove</button> | |
| 1400 | 1571 | </div> |
| 1401 | 1572 | @endforeach |
| 1402 | 1573 | </div> |
| @@ -1404,7 +1575,7 @@ onMount(() => { | ||
| 1404 | 1575 | <div class="mb-3 font-semibold text-sm">Invite a teammate</div> |
| 1405 | 1576 | <div class="flex flex-wrap gap-2 items-center"> |
| 1406 | 1577 | <input :model="inviteEmail" type="email" aria-label="Invite a teammate by email" class="flex-1 px-3.5 py-2.5 min-w-[220px] text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="teammate@company.com" autocomplete="off"> |
| 1407 | <button @click="addInvite" :disabled="inviting()" class="px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px] disabled:opacity-55 disabled:cursor-default [transition:background_0.15s_ease,opacity_0.15s_ease]" :text="inviting() ? 'Inviting…' : 'Invite'">Invite</button> | |
| 1578 | <Button variant="ghost" @click="addInvite" :loading="inviting()" className="btn-cta btn-solid">Invite</Button> | |
| 1408 | 1579 | </div> |
| 1409 | 1580 | <span class="mt-2.5 text-ok text-xs" :show="!!inviteNote()" :class="inviteNoteOk() ? 'mt-2.5 text-xs text-ok' : 'mt-2.5 text-xs text-accent'">{{ inviteNote() }}</span> |
| 1410 | 1581 | <p class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| @@ -1417,7 +1588,7 @@ onMount(() => { | ||
| 1417 | 1588 | <section id="alerts" class="mb-10 scroll-mt-[84px]"> |
| 1418 | 1589 | <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Alerts</div> |
| 1419 | 1590 | <p class="mb-4 text-sm text-subtle"> |
| 1420 | Add a Slack or Discord webhook and send a test to verify it. Automatic delivery isn't live yet — | |
| 1591 | Add a Slack or Discord webhook and send a test to verify it. Automatic delivery isn't live yet: | |
| 1421 | 1592 | channels you add here are saved and testable, but loghq won't fire on its own until log-based alert rules ship. |
| 1422 | 1593 | </p> |
| 1423 | 1594 | <div class="flex flex-col gap-2.5 mb-4"> |
| @@ -1435,7 +1606,7 @@ onMount(() => { | ||
| 1435 | 1606 | <div class="font-medium text-sm truncate">{{ c.label }}</div> |
| 1436 | 1607 | <div class="mt-0.5 font-mono text-subtle text-xs truncate">{{ c.mask }}</div> |
| 1437 | 1608 | </div> |
| 1438 | <button @click="testChannel('')" :text="testLabel('')" class="px-2.5 py-1.5 font-medium text-muted text-xs hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]" x-tooltip="Send a test message">Test</button> | |
| 1609 | <button @click="testChannel('')" :text="testLabel('')" class="btn-quiet btn-xs" x-tooltip="Send a test message">Test</button> | |
| 1439 | 1610 | {{-- A real <button>, not a <div role="switch">. As a div this had |
| 1440 | 1611 | no tabindex, no key handler and no accessible name: measured |
| 1441 | 1612 | in a browser it reported focusable:false and ariaLabel:null, |
| @@ -1452,7 +1623,7 @@ onMount(() => { | ||
| 1452 | 1623 | match the component instead of the product. The a11y gap was |
| 1453 | 1624 | the defect; the platform element closes it. --}} |
| 1454 | 1625 | <button type="button" role="switch" aria-label="Enable " data-on="" aria-checked="" class="relative flex-none p-0 h-[22px] w-[38px] bg-line border-none rounded-[999px] appearance-none cursor-pointer [transition:background_0.15s_ease] tgl" @click="toggleChannel('', )" :data-on="isChannelOn('', ) ? '1' : '0'" :aria-checked="isChannelOn('', ) ? 'true' : 'false'" x-tooltip="Enable / disable"></button> |
| 1455 | <button @click="deleteChannel('')" :text="deleteLabel('')" class="px-2.5 py-1.5 font-medium text-warn text-xs hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]" x-tooltip="Remove">Remove</button> | |
| 1626 | <button @click="deleteChannel('')" :text="deleteLabel('')" class="btn-danger btn-xs" x-tooltip="Remove">Remove</button> | |
| 1456 | 1627 | </div> |
| 1457 | 1628 | @empty |
| 1458 | 1629 | <EmptyState title="No chat alerts yet" description="Add a Slack or Discord webhook below to start getting pinged." /> |
| @@ -1465,9 +1636,9 @@ onMount(() => { | ||
| 1465 | 1636 | <button type="button" @click="chType.set('discord')" data-sel="0" :data-sel="chType() === 'discord' ? '1' : '0'" class="px-3 py-2.5 font-medium text-muted text-sm bg-panel border border-line rounded-[10px] [transition:all_0.12s_ease] seg seg-discord">Discord</button> |
| 1466 | 1637 | </div> |
| 1467 | 1638 | <input :model="whUrl" :placeholder="whPlaceholder()" type="url" aria-label="Webhook URL" class="mb-2 px-3.5 py-2.5 w-full font-mono text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="https://hooks.slack.com/services/…" autocomplete="off" spellcheck="false"> |
| 1468 | <input :model="whLabel" type="text" aria-label="Channel label" class="mb-3 px-3.5 py-2.5 w-full text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="Label (optional) — e.g. #alerts" maxlength="255"> | |
| 1639 | <input :model="whLabel" type="text" aria-label="Channel label" class="mb-3 px-3.5 py-2.5 w-full text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" placeholder="Label (optional), e.g. #alerts" maxlength="255"> | |
| 1469 | 1640 | <div class="flex gap-3 items-center"> |
| 1470 | <button @click="addChannel" :disabled="adding()" class="px-4 py-2.5 font-semibold text-accent-ink text-sm bg-accent hover:bg-[color-mix(in_srgb,var(--accent)_88%,#000)] rounded-[10px] disabled:opacity-55 disabled:cursor-default [transition:background_0.15s_ease,opacity_0.15s_ease]" :text="adding() ? 'Adding…' : 'Add channel'">Add channel</button> | |
| 1641 | <Button variant="ghost" @click="addChannel" :loading="adding()" className="btn-cta btn-solid">Add channel</Button> | |
| 1471 | 1642 | <span class="text-ok text-xs" :show="!!whNote()" :class="whNoteOk() ? 'text-xs text-ok' : 'text-xs text-accent'">{{ whNote() }}</span> |
| 1472 | 1643 | </div> |
| 1473 | 1644 | <p :text="whHelp()" class="mt-3.5 leading-relaxed text-subtle text-xs"> |
| @@ -1557,10 +1728,10 @@ onMount(() => { | ||
| 1557 | 1728 | <p class="text-sm text-subtle"><span class="font-mono text-muted">{{ activeProject?.name }}</span> is archived and isn’t accepting new logs. Unarchiving resumes ingestion right away.</p> |
| 1558 | 1729 | @else |
| 1559 | 1730 | <div class="mb-0.5 font-semibold text-sm">Archive this project</div> |
| 1560 | <p class="text-sm text-subtle">Rejects new log entries for <span class="font-mono text-muted">{{ activeProject?.name }}</span> with a 403. Everything already stored is kept — you can unarchive anytime.</p> | |
| 1731 | <p class="text-sm text-subtle">Rejects new log entries for <span class="font-mono text-muted">{{ activeProject?.name }}</span> with a 403. Everything already stored is kept, and you can unarchive anytime.</p> | |
| 1561 | 1732 | @endif |
| 1562 | 1733 | </div> |
| 1563 | <button @click="openArchive" :disabled="archiving()" class="flex-none px-4 py-2.5 font-semibold text-sm text-warn whitespace-nowrap hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</button> | |
| 1734 | <Button variant="ghost" @click="openArchive" :loading="archiving()" className="flex-none whitespace-nowrap btn-cta btn-danger">{{ activeProject?.is_active === false ? 'Unarchive' : 'Archive' }}</Button> | |
| 1564 | 1735 | </div> |
| 1565 | 1736 | <div class="h-px bg-[color-mix(in_srgb,var(--warn)_20%,var(--border))]"></div> |
| 1566 | 1737 | {{-- Delete --}} |
| @@ -1569,7 +1740,7 @@ onMount(() => { | ||
| 1569 | 1740 | <div class="mb-0.5 font-semibold text-sm">Delete this project</div> |
| 1570 | 1741 | <p class="text-sm text-subtle">Permanently deletes <span class="font-mono text-muted">{{ activeProject?.name }}</span> and all of its log entries, alert channels, members, and invites. This cannot be undone.</p> |
| 1571 | 1742 | </div> |
| 1572 | <button @click="openDelete" class="flex-none px-4 py-2.5 font-semibold text-sm text-warn whitespace-nowrap hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Delete</button> | |
| 1743 | <button @click="openDelete" class="flex-none whitespace-nowrap btn-cta btn-danger">Delete</button> | |
| 1573 | 1744 | </div> |
| 1574 | 1745 | </div> |
| 1575 | 1746 | </section> |
| @@ -1592,8 +1763,8 @@ onMount(() => { | ||
| 1592 | 1763 | <label class="block mb-1.5 text-subtle text-xs">Type <span class="font-mono text-muted">{{ projectName }}</span> to confirm</label> |
| 1593 | 1764 | <input :model="modalInput" type="text" :aria-label="modalTitle()" aria-label="Type the project name to confirm" class="mb-4 px-3.5 py-2.5 w-full font-mono text-ink text-sm bg-canvas border border-line rounded-[10px] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:outline-none" autocomplete="off" autocapitalize="off" spellcheck="false"> |
| 1594 | 1765 | <div class="flex gap-2 items-center justify-end"> |
| 1595 | <button @click="closeModal" type="button" class="px-4 py-2.5 font-medium text-muted text-sm hover:text-ink bg-panel border border-line rounded-[9px] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--border))] [transition:color_0.15s_ease,border-color_0.15s_ease]">Cancel</button> | |
| 1596 | <button @click="confirmModal" :disabled="!modalReady()" :text="modalConfirmLabel()" type="button" class="px-4 py-2.5 font-semibold text-sm text-warn hover:text-accent-ink bg-panel hover:bg-warn border border-[color-mix(in_srgb,var(--warn)_45%,var(--border))] rounded-[9px] hover:border-warn [transition:all_0.15s_ease]">Confirm</button> | |
| 1766 | <button @click="closeModal" type="button" class="btn-md btn-quiet">Cancel</button> | |
| 1767 | <Button type="button" variant="ghost" @click="confirmModal" :disabled="!modalReady()" :loading="modalBusy()" :label="modalConfirmLabel()" className="btn-cta btn-danger">Confirm</Button> | |
| 1597 | 1768 | </div> |
| 1598 | 1769 | <span class="mt-2.5 text-accent text-xs" :show="!!modalNote()">{{ modalNote() }}</span> |
| 1599 | 1770 | </div> |
| @@ -15,9 +15,9 @@ import { rateLimit } from '../app/Errors/limits' | ||
| 15 | 15 | import { extractEntries, LEVELS, MAX_BATCH, MAX_CORRELATION, normalizeBatch } from '../app/Logs/normalize' |
| 16 | 16 | // The owner/member predicate lives in one file so these routes and the pages |
| 17 | 17 | // that render the same data cannot drift on it. See app/Support/access.ts. |
| 18 | import { ownsLog, ownsProject } from '../app/Support/access' | |
| 18 | import { ownsProject } from '../app/Support/access' | |
| 19 | 19 | import { healthResponse } from '../app/Support/health' |
| 20 | import { userFromRequest } from '../app/Support/request-auth' | |
| 20 | import { projectReadAccess } from '../app/Support/request-auth' | |
| 21 | 21 | |
| 22 | 22 | // Ingest abuse bounds. The public key gate is not enough on its own — a script |
| 23 | 23 | // with the key (readable from any bundle) could flood the ingest. Per-entry |
| @@ -137,11 +137,10 @@ const STREAM_COLS = 'id, level, message, channel, environment, release, framewor | ||
| 137 | 137 | |
| 138 | 138 | route.get('/api/projects/{projectId}/logs', async (request: any) => { |
| 139 | 139 | const projectId = request.params.projectId |
| 140 | const user = await userFromRequest(request) | |
| 141 | if (!user) | |
| 142 | return json({ error: 'unauthorized' }, 401) | |
| 143 | if (!(await ownsProject(user, projectId))) | |
| 144 | return json({ error: 'not found' }, 404) | |
| 140 | // A person who owns the project, or a read key issued for it (#14). | |
| 141 | const access = await projectReadAccess(request, projectId, ownsProject) | |
| 142 | if (!access.ok) | |
| 143 | return json({ error: access.error }, access.status) | |
| 145 | 144 | |
| 146 | 145 | const q = request.query ?? {} |
| 147 | 146 | const where: string[] = ['project_id = $1'] |
| @@ -207,12 +206,16 @@ route.get('/api/projects/{projectId}/logs', async (request: any) => { | ||
| 207 | 206 | |
| 208 | 207 | route.get('/api/logs/{logId}', async (request: any) => { |
| 209 | 208 | const logId = request.params.logId |
| 210 | const user = await userFromRequest(request) | |
| 211 | if (!user) | |
| 212 | return json({ error: 'unauthorized' }, 401) | |
| 213 | if (!(await ownsLog(user, logId))) | |
| 214 | return json({ error: 'not found' }, 404) | |
| 209 | // The entry is read before anyone is authorised, and returned only after. | |
| 210 | // Authorising first would mean asking "may you read project X?" without | |
| 211 | // knowing which project this entry is in; answering 404 for a missing entry | |
| 212 | // before checking the caller would tell an unauthenticated one which log ids | |
| 213 | // exist. So: fetch, authorise against the entry's project (an empty id when | |
| 214 | // there is no entry, which no key and no user can read), then answer. | |
| 215 | 215 | const entry = (await db.unsafe('SELECT * FROM log_entries WHERE id = $1 LIMIT 1', [logId]))?.[0] |
| 216 | const access = await projectReadAccess(request, entry ? String(entry.project_id) : '', ownsProject) | |
| 217 | if (!access.ok) | |
| 218 | return json({ error: access.error }, access.status) | |
| 216 | 219 | if (!entry) |
| 217 | 220 | return json({ error: 'not found' }, 404) |
| 218 | 221 | return json({ entry }) |
| @@ -17,6 +17,7 @@ import { log } from '@stacksjs/logging' | ||
| 17 | 17 | // Shared with the page action in resources/views/projects/new.stx, so the two |
| 18 | 18 | // callers cannot drift on how an id or an ingest key is minted. |
| 19 | 19 | import { newIngestKey, newProjectId } from '../app/Support/projects' |
| 20 | import { newReadKey } from '../app/Support/read-key' | |
| 20 | 21 | import { utcNow } from '../app/Support/time' |
| 21 | 22 | import { GitHubRequestError, verifyAccess } from '../app/Fix/github' |
| 22 | 23 | import { |
| @@ -114,6 +115,9 @@ route.get('/api/projects', async (request: any) => { | ||
| 114 | 115 | return json({ error: 'unauthorized' }, 401) |
| 115 | 116 | const rows = await db.unsafe( |
| 116 | 117 | `SELECT DISTINCT p.id, p.name, p.platform, p.ingest_key, p.is_active, p.created_at, |
| 118 | -- Whether a read key exists and when it was last used, never the | |
| 119 | -- key: it is a secret, and it is shown once when it is issued. | |
| 120 | (p.read_key IS NOT NULL) AS has_read_key, p.read_key_last_used_at, | |
| 117 | 121 | (p.owner_id = $1) AS is_owner |
| 118 | 122 | FROM projects p |
| 119 | 123 | LEFT JOIN project_members m ON m.project_id = p.id AND lower(m.email) = $2 |
| @@ -183,6 +187,44 @@ route.post('/api/projects/{projectId}/rotate-key', async (request: any) => { | ||
| 183 | 187 | return json({ ingest_key: key }) |
| 184 | 188 | }).skipCsrf() |
| 185 | 189 | |
| 190 | // Issue (or replace) a project's read key. Owner-only. | |
| 191 | // | |
| 192 | // This is the credential a server presents to read the project's logs without | |
| 193 | // a person's account behind it (#14). It is returned here and nowhere else: | |
| 194 | // unlike the public ingest key, which the listing carries so it can be copied | |
| 195 | // into client code, this one is a secret, so it is shown once and the listing | |
| 196 | // afterwards says only that it exists. Losing it means issuing another, which | |
| 197 | // is the same call — and the previous key stops working the moment it lands. | |
| 198 | route.post('/api/projects/{projectId}/read-key', async (request: any) => { | |
| 199 | const user = await currentUser(request) | |
| 200 | if (!user) | |
| 201 | return json({ error: 'unauthorized' }, 401) | |
| 202 | const projectId = request.params.projectId | |
| 203 | if (!(await ownsProject(user, projectId))) | |
| 204 | return json({ error: 'not found' }, 404) | |
| 205 | ||
| 206 | const key = newReadKey() | |
| 207 | // The last-used stamp belongs to the key, so a new key starts with none: | |
| 208 | // leaving the old one would read as "this key was used before it existed". | |
| 209 | await db.unsafe('UPDATE projects SET read_key = $1, read_key_last_used_at = NULL, updated_at = $3 WHERE id = $2', [key, projectId, utcNow()]) | |
| 210 | return json({ read_key: key }) | |
| 211 | }).skipCsrf() | |
| 212 | ||
| 213 | // Revoke a project's read key. Owner-only. Anything still presenting it gets a | |
| 214 | // 404 from the read API on the next request, the same answer an unknown | |
| 215 | // project gets. | |
| 216 | route.delete('/api/projects/{projectId}/read-key', async (request: any) => { | |
| 217 | const user = await currentUser(request) | |
| 218 | if (!user) | |
| 219 | return json({ error: 'unauthorized' }, 401) | |
| 220 | const projectId = request.params.projectId | |
| 221 | if (!(await ownsProject(user, projectId))) | |
| 222 | return json({ error: 'not found' }, 404) | |
| 223 | ||
| 224 | await db.unsafe('UPDATE projects SET read_key = NULL, read_key_last_used_at = NULL, updated_at = $2 WHERE id = $1', [projectId, utcNow()]) | |
| 225 | return json({ revoked: true }) | |
| 226 | }).skipCsrf() | |
| 227 | ||
| 186 | 228 | // Delete a project and everything under it. Owner-only. Removes children first |
| 187 | 229 | // to satisfy the foreign keys (log entries/channels/members/invites -> project), |
| 188 | 230 | // then the project row. |
| @@ -4,15 +4,17 @@ import { resolve } from 'node:path' | ||
| 4 | 4 | interface SmokeContract { |
| 5 | 5 | domain: string |
| 6 | 6 | homeText: string |
| 7 | /** Whether POST /api/email/subscribe is registered in that app. */ | |
| 8 | subscribe: boolean | |
| 7 | 9 | } |
| 8 | 10 | |
| 9 | 11 | const CONTRACTS: Record<string, SmokeContract> = { |
| 10 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' }, | |
| 11 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' }, | |
| 12 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' }, | |
| 13 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' }, | |
| 14 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' }, | |
| 15 | status: { domain: 'statushq.org', homeText: 'Know the moment' }, | |
| 12 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq', subscribe: true }, | |
| 13 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.', subscribe: false }, | |
| 14 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.', subscribe: true }, | |
| 15 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.', subscribe: true }, | |
| 16 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves', subscribe: true }, | |
| 17 | status: { domain: 'statushq.org', homeText: 'Know the moment', subscribe: true }, | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | const projectRoot = resolve(import.meta.dir, '..') |
| @@ -89,6 +91,84 @@ for (const [label, body] of [['homepage', home], ['login', login]] as const) { | ||
| 89 | 91 | } |
| 90 | 92 | } |
| 91 | 93 | |
| 94 | /* | |
| 95 | * The newsletter endpoint, which is really a database read wearing a form. | |
| 96 | * | |
| 97 | * Added because two sites served a 500 here for an unknown length of time and | |
| 98 | * every gate stayed green: loghq had never created the `subscribers` table at | |
| 99 | * all, and reportshq deploys had never run a migration. The checks above cannot | |
| 100 | * see it. They read the homepage, the login page and robots.txt, and all three | |
| 101 | * were perfect on both sites throughout. | |
| 102 | * | |
| 103 | * THE PAYLOAD IS THE WHOLE POINT, and an invalid one would be useless here. | |
| 104 | * The framework action validates before it queries: | |
| 105 | * | |
| 106 | * if (!email || !email.includes('@')) return { success: false, ... } | |
| 107 | * | |
| 108 | * so an empty body returns 200 WITHOUT TOUCHING THE DATABASE. Measured against | |
| 109 | * the two broken sites while they were broken: `{}` gave 200 and a real address | |
| 110 | * gave 500. A smoke check built on the polite empty payload would have passed | |
| 111 | * on both. | |
| 112 | * | |
| 113 | * So it sends a real address, and a FIXED one. The action looks the address up | |
| 114 | * before it writes and returns "Already subscribed" when it finds it, so the | |
| 115 | * first run after a database is created writes one row and every run after that | |
| 116 | * is a pure read of the table this is here to prove exists. A fresh address per | |
| 117 | * run would add a row per deploy forever. | |
| 118 | * | |
| 119 | * `.invalid` is reserved by RFC 2606 and resolves nowhere, so the row can never | |
| 120 | * become mail to a real person even if someone later points a campaign at the | |
| 121 | * subscriber list. | |
| 122 | */ | |
| 123 | const SMOKE_SUBSCRIBER = 'smoke-probe@example.invalid' | |
| 124 | ||
| 125 | async function assertSubscribeWorks(): Promise<void> { | |
| 126 | let lastFailure = 'no response' | |
| 127 | for (let attempt = 1; attempt <= attempts; attempt += 1) { | |
| 128 | try { | |
| 129 | const response = await fetch(`${baseUrl}/api/email/subscribe`, { | |
| 130 | method: 'POST', | |
| 131 | headers: { 'content-type': 'application/json', 'accept': 'application/json' }, | |
| 132 | body: JSON.stringify({ email: SMOKE_SUBSCRIBER, source: 'deploy-smoke' }), | |
| 133 | signal: AbortSignal.timeout(10_000), | |
| 134 | }) | |
| 135 | const body = await response.text() | |
| 136 | ||
| 137 | // A 5xx is the failure this exists to catch, and it is worth naming | |
| 138 | // separately: it means the route resolved and the handler threw, which is | |
| 139 | // the missing-table shape rather than a missing route. | |
| 140 | if (response.status >= 500) { | |
| 141 | throw new Error( | |
| 142 | `${baseUrl}/api/email/subscribe answered ${response.status}. The route is registered and ` | |
| 143 | + `the handler threw, which is what a missing \`subscribers\` table looks like from outside. ` | |
| 144 | + `Check that migrations ran on this box. Body: ${body.slice(0, 200)}`, | |
| 145 | ) | |
| 146 | } | |
| 147 | ||
| 148 | if (!response.ok) { | |
| 149 | lastFailure = `HTTP ${response.status}` | |
| 150 | } | |
| 151 | else { | |
| 152 | const parsed = JSON.parse(body) as { success?: boolean, message?: string } | |
| 153 | if (parsed.success !== true) | |
| 154 | throw new Error(`Subscribe endpoint returned success=${parsed.success}: ${body.slice(0, 200)}`) | |
| 155 | return | |
| 156 | } | |
| 157 | } | |
| 158 | catch (error) { | |
| 159 | // A thrown assertion is a real failure and must not be retried into a | |
| 160 | // timeout; only transport problems are worth another attempt. | |
| 161 | if (error instanceof Error && error.message.startsWith(baseUrl)) throw error | |
| 162 | if (error instanceof Error && error.message.startsWith('Subscribe endpoint')) throw error | |
| 163 | lastFailure = error instanceof Error ? error.message : String(error) | |
| 164 | } | |
| 165 | if (attempt < attempts) await Bun.sleep(pauseMs) | |
| 166 | } | |
| 167 | throw new Error(`${baseUrl}/api/email/subscribe failed after ${attempts} attempts: ${lastFailure}`) | |
| 168 | } | |
| 169 | ||
| 170 | if (contract.subscribe) await assertSubscribeWorks() | |
| 171 | ||
| 92 | 172 | const robots = await readWithRetry('/robots.txt') |
| 93 | 173 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') |
| 94 | 174 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) |
| @@ -1,14 +1,14 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 1 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | 2 | import { join, relative, resolve } from 'node:path' |
| 3 | 3 | |
| 4 | 4 | const root = process.cwd() |
| 5 | 5 | const resources = resolve(root, 'resources') |
| 6 | 6 | const issues = new Set<string>() |
| 7 | 7 | |
| 8 | function filesIn(directory: string): string[] { | |
| 8 | function filesIn(directory: string, pattern = /\.(?:stx|html|css)$/): string[] { | |
| 9 | 9 | return readdirSync(directory).flatMap((name) => { |
| 10 | 10 | const path = join(directory, name) |
| 11 | return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : [] | |
| 11 | return statSync(path).isDirectory() ? filesIn(path, pattern) : pattern.test(path) ? [path] : [] | |
| 12 | 12 | }) |
| 13 | 13 | } |
| 14 | 14 | |
| @@ -35,7 +35,21 @@ function report(file: string, source: string, index: number, message: string): v | ||
| 35 | 35 | |
| 36 | 36 | const files = filesIn(resources) |
| 37 | 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 38 | /* | |
| 39 | * The two app-wide checks at the bottom read resources/ PLUS the CSS config. | |
| 40 | * Resources alone stopped being enough once page <style> blocks moved into | |
| 41 | * config/crosswind.ts under stx-standards rule 3: the focus and breakpoint | |
| 42 | * treatments are still declared, in the one <style data-crosswind> tag the | |
| 43 | * router preserves, and a resources-only corpus cannot see them. statushq hit | |
| 44 | * exactly that -- CI red on main and deploy-prod skipped for two commits. | |
| 45 | * Missing the config throws rather than reading empty, because a check with | |
| 46 | * nothing to search always passes. | |
| 47 | */ | |
| 48 | const cssConfig = resolve(root, 'config/crosswind.ts') | |
| 49 | if (!existsSync(cssConfig)) | |
| 50 | throw new Error(`validate-ui: expected the CSS config at ${relative(root, cssConfig)}`) | |
| 51 | ||
| 52 | const combined = [...files, cssConfig].map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | 53 | |
| 40 | 54 | for (const file of markupFiles) { |
| 41 | 55 | const original = readFileSync(file, 'utf8') |
| @@ -78,9 +92,128 @@ for (const file of markupFiles) { | ||
| 78 | 92 | } |
| 79 | 93 | } |
| 80 | 94 | |
| 95 | /* | |
| 96 | * Em-dashes in copy a user can read. | |
| 97 | * | |
| 98 | * The house rule is in AGENTS.md: never an em-dash or a separator en-dash in a | |
| 99 | * headline, a body string, a label, a button, alt text, a tooltip or a | |
| 100 | * placeholder. It is named there as the most common AI design tell and listed as | |
| 101 | * a pre-flight item, and nothing enforced it -- 98 live ones across the six apps | |
| 102 | * when this check was written, every one shipped past a green CI. | |
| 103 | * | |
| 104 | * Over a wider corpus than the markup checks above, because copy is not only in | |
| 105 | * templates: resources/data/*.ts holds marketing tables and resources/emails/ | |
| 106 | * holds mail bodies, and both reach a reader. filesIn() takes the pattern for | |
| 107 | * that reason; `combined` deliberately keeps the narrow one, since widening the | |
| 108 | * corpus of a check that looks for a treatment can only add ways for it to pass. | |
| 109 | * | |
| 110 | * Two shapes are not separators and are exempt: | |
| 111 | * | |
| 112 | * '—' a lone dash standing in for a value that does not exist (a field the | |
| 113 | * SDK never sent, a count we never measured). A typographic placeholder | |
| 114 | * rather than punctuation between clauses, and no hyphen reads as | |
| 115 | * "no value". | |
| 116 | * 1–10 an en-dash between digits, which is a numeric range and the correct | |
| 117 | * character for one. The rule bans the SEPARATOR en-dash, the one | |
| 118 | * standing in for a comma or a full stop. | |
| 119 | */ | |
| 120 | for (const file of filesIn(resources, /\.(?:stx|html|ts)$/)) { | |
| 121 | const source = withoutComments(readFileSync(file, 'utf8')) | |
| 122 | for (const match of source.matchAll(/[\u2014\u2013]/g)) { | |
| 123 | const before = source.slice(Math.max(0, match.index - 1), match.index) | |
| 124 | const after = source.slice(match.index + 1, match.index + 2) | |
| 125 | if (/['"`>]/.test(before) && /['"`<]/.test(after)) | |
| 126 | continue | |
| 127 | if (match[0] === '\u2013' && /\d/.test(before) && /\d/.test(after)) | |
| 128 | continue | |
| 129 | report(file, source, match.index, 'em-dash in user-visible copy: use a hyphen, a comma, or two sentences') | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | /* | |
| 134 | * Rule 11.5: no style="" for anything a utility can express. | |
| 135 | * | |
| 136 | * A hand-written declaration on an element is invisible to Crosswind, which | |
| 137 | * extracts from class= attributes only, so it cannot be deduped, purged or | |
| 138 | * themed -- and it silently outranks every utility and shortcut on that | |
| 139 | * element, because an inline style beats any layer. | |
| 140 | * | |
| 141 | * Three shapes are legitimate and exempt. Each was measured, not assumed: | |
| 142 | * | |
| 143 | * style="width: %" a value computed per render and unbounded: a | |
| 144 | * bar height, a progress width. The rule names | |
| 145 | * this case. It stays a mustache rather than | |
| 146 | * becoming the :style binding the rule suggests, | |
| 147 | * because these are SERVER values and :style is | |
| 148 | * evaluated in a browser where the server's data | |
| 149 | * does not exist. | |
| 150 | * | |
| 151 | * :style="progress()" the client-bound form, for a value the browser | |
| 152 | * computes. | |
| 153 | * | |
| 154 | * style="display:none" beside owned by the runtime, and NOT convertible. | |
| 155 | * :show= or :hidden= setVisible() in dist/signals.js reads | |
| 156 | * node.style.display -- the INLINE value, not | |
| 157 | * the computed one -- remembers it, and unhides | |
| 158 | * by writing it back, mapping a 'none' it finds | |
| 159 | * there to ''. So class="hidden" plus :show | |
| 160 | * would unhide to display:'' and fall straight | |
| 161 | * back to the class: hidden forever. The inline | |
| 162 | * none is what makes the element correct before | |
| 163 | * hydration and restorable after it. | |
| 164 | * | |
| 165 | * Two directories are excluded, both because no stylesheet of ours reaches | |
| 166 | * them: | |
| 167 | * | |
| 168 | * resources/emails/ an HTML email has no stylesheet that can reach | |
| 169 | * it and mail clients honour inline CSS only. | |
| 170 | * | |
| 171 | * a component directory that the chart components are distributed to | |
| 172 | * ships its own .css applications that have never heard of | |
| 173 | * Crosswind, so they carry charts.css and every | |
| 174 | * class they use has to be in it (enforced by | |
| 175 | * tests/feature/chart-components.test.ts). A | |
| 176 | * utility class there is INERT, which is the | |
| 177 | * silent-layout-bug case that test exists for -- | |
| 178 | * it caught four legend swatches converted to | |
| 179 | * bg-[color-mix(...)] during this very sweep. | |
| 180 | * The colour of a swatch is the one thing that | |
| 181 | * must survive with no stylesheet at all, so | |
| 182 | * inline is the portable spelling there, and | |
| 183 | * moving it would mean regenerating six | |
| 184 | * distributed artifacts to say the same thing. | |
| 185 | * Detected by a sibling .css rather than by | |
| 186 | * path, so the next such component is covered | |
| 187 | * without being named. | |
| 188 | */ | |
| 189 | const shipsOwnStylesheet = (file: string): boolean => | |
| 190 | files.some(other => other.endsWith('.css') && other.slice(0, other.lastIndexOf('/')) === file.slice(0, file.lastIndexOf('/'))) | |
| 191 | ||
| 192 | for (const file of markupFiles) { | |
| 193 | if (file.includes('/emails/') || shipsOwnStylesheet(file)) | |
| 194 | continue | |
| 195 | const source = withoutComments(readFileSync(file, 'utf8')) | |
| 196 | for (const match of source.matchAll(/(?:^|[^:\w-])style="([^"]*)"/g)) { | |
| 197 | const value = match[1] | |
| 198 | if (value.includes('{{')) | |
| 199 | continue | |
| 200 | const tag = source.slice(source.lastIndexOf('<', match.index), source.indexOf('>', match.index) + 1) | |
| 201 | if (/^\s*display:\s*none;?\s*$/.test(value) && /:(?:show|hidden)=/.test(tag)) | |
| 202 | continue | |
| 203 | const shown = value.length > 48 ? `${value.slice(0, 48)}...` : value | |
| 204 | report(file, source, match.index, `inline style="${shown}" belongs in a class (stx-standards 11.5)`) | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 81 | 208 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) |
| 82 | 209 | issues.add('resources: no visible keyboard focus treatment found') |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 210 | /* | |
| 211 | * The utility half needs a class-token boundary, not \b: \b sits between the | |
| 212 | * hyphen and the s in `--radius-sm:`, so a single custom property name | |
| 213 | * satisfied this check on its own and it could not fail. Found in statushq, | |
| 214 | * where every @media block could be removed and the check still passed. | |
| 215 | */ | |
| 216 | if (!/(?:@media|(?:^|[\s"'`:])(?:sm|md|lg|xl):)/m.test(combined)) | |
| 84 | 217 | issues.add('resources: no responsive breakpoint treatment found') |
| 85 | 218 | |
| 86 | 219 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) |
| @@ -70,6 +70,48 @@ async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise | ||
| 70 | 70 | return value |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | /** | |
| 74 | * Wait for Chrome to publish its DevTools port, and say something useful when it | |
| 75 | * does not. | |
| 76 | * | |
| 77 | * This used to be a plain `waitFor(() => existsSync(portFile), 10_000)` against a | |
| 78 | * Chrome spawned with stderr discarded, so every launch failure — missing shared | |
| 79 | * library, unwritable profile, sandbox refusal, OOM — arrived as the same four | |
| 80 | * words, `Timed out after 10000ms`, with the one line that would have explained it | |
| 81 | * thrown away. It failed twice in three CI runs and was undiagnosable both times. | |
| 82 | * | |
| 83 | * Two changes: the process is polled as well as the file, so a Chrome that exits | |
| 84 | * reports immediately instead of burning the whole timeout first; and whatever it | |
| 85 | * wrote to stderr is attached to the error. | |
| 86 | */ | |
| 87 | async function waitForDevToolsPort( | |
| 88 | portFile: string, | |
| 89 | chrome: { exitCode: number | null }, | |
| 90 | stderr: () => string, | |
| 91 | chromeBinary: string, | |
| 92 | timeout = 30_000, | |
| 93 | ): Promise<void> { | |
| 94 | const started = Date.now() | |
| 95 | const describe = (headline: string): string => { | |
| 96 | const tail = stderr().trim().split('\n').slice(-12).join('\n') | |
| 97 | return [ | |
| 98 | headline, | |
| 99 | ` binary: ${chromeBinary}`, | |
| 100 | ` profile: ${portFile.replace(/\/DevToolsActivePort$/, '')}`, | |
| 101 | tail ? ` stderr:\n${tail.replace(/^/gm, ' ')}` : ' stderr: (nothing was written)', | |
| 102 | ].join('\n') | |
| 103 | } | |
| 104 | ||
| 105 | while (!existsSync(portFile)) { | |
| 106 | // A dead Chrome will never write the file, so there is nothing to wait for. | |
| 107 | if (chrome.exitCode !== null) | |
| 108 | throw new Error(describe(`Chrome exited with code ${chrome.exitCode} before opening a debugging port.`)) | |
| 109 | if (Date.now() - started >= timeout) | |
| 110 | throw new Error(describe(`Chrome did not open a debugging port within ${timeout}ms.`)) | |
| 111 | await Bun.sleep(100) | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 73 | 115 | class Cdp { |
| 74 | 116 | private id = 0 |
| 75 | 117 | private pending = new Map<number, Pending>() |
| @@ -237,8 +279,9 @@ async function main(): Promise<void> { | ||
| 237 | 279 | }, |
| 238 | 280 | }) |
| 239 | 281 | const profile = mkdtempSync(join(tmpdir(), 'hq-visual-')) |
| 282 | const chromeBinary = chromePath() | |
| 240 | 283 | const chrome = Bun.spawn([ |
| 241 | chromePath(), | |
| 284 | chromeBinary, | |
| 242 | 285 | '--headless=new', |
| 243 | 286 | '--remote-debugging-port=0', |
| 244 | 287 | `--user-data-dir=${profile}`, |
| @@ -255,13 +298,28 @@ async function main(): Promise<void> { | ||
| 255 | 298 | '--disable-dev-shm-usage', |
| 256 | 299 | '--disable-gpu', |
| 257 | 300 | 'about:blank', |
| 258 | ], { stdout: 'ignore', stderr: 'ignore' }) | |
| 301 | ], { stdout: 'ignore', stderr: 'pipe' }) | |
| 302 | ||
| 303 | // Drained continuously rather than read on failure: a chatty Chrome can fill the | |
| 304 | // pipe buffer and block on its own output, which would look exactly like the | |
| 305 | // startup hang this is here to explain. | |
| 306 | let chromeStderr = '' | |
| 307 | const drainingStderr = (async () => { | |
| 308 | try { | |
| 309 | const decoder = new TextDecoder() | |
| 310 | for await (const chunk of chrome.stderr as ReadableStream<Uint8Array>) | |
| 311 | chromeStderr += decoder.decode(chunk, { stream: true }) | |
| 312 | } | |
| 313 | catch { | |
| 314 | // The process was killed mid-read; whatever arrived is still worth having. | |
| 315 | } | |
| 316 | })() | |
| 259 | 317 | |
| 260 | 318 | const failures: string[] = [] |
| 261 | 319 | let screenshots = 0 |
| 262 | 320 | try { |
| 263 | 321 | const portFile = join(profile, 'DevToolsActivePort') |
| 264 | await waitFor(() => existsSync(portFile), 10_000) | |
| 322 | await waitForDevToolsPort(portFile, chrome, () => chromeStderr, chromeBinary) | |
| 265 | 323 | const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]) |
| 266 | 324 | const targets = await waitFor(async () => { |
| 267 | 325 | const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => []) |
| @@ -321,6 +379,7 @@ async function main(): Promise<void> { | ||
| 321 | 379 | finally { |
| 322 | 380 | chrome.kill() |
| 323 | 381 | await chrome.exited |
| 382 | await drainingStderr | |
| 324 | 383 | server.stop(true) |
| 325 | 384 | rmSync(profile, { force: true, recursive: true }) |
| 326 | 385 | } |
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * A page that cannot serve you must not offer you its controls. | |
| 3 | * | |
| 4 | * Four pages across this estate rendered their signed-in chrome directly above | |
| 5 | * their own signed-out gate: "+ New app" and "Log out" over a panel reading | |
| 6 | * "You're signed out", a fixed "Log out" button floating over the sign-in | |
| 7 | * prompt, a full reporting toolbar - range presets, Compare, CSV export - above | |
| 8 | * "Sign in to view analytics". Nothing leaked. Every one of those controls | |
| 9 | * answers 401, or logs nobody out, or re-queries a report whose payload comes | |
| 10 | * back with every number at zero. The bug is what it says to the visitor: a | |
| 11 | * page offering controls it cannot honour reads as broken rather than as | |
| 12 | * locked, and "Log out" in particular claims the visitor is signed in while the | |
| 13 | * panel beneath it says they are not. | |
| 14 | * | |
| 15 | * Two legitimate vehicles, and which one is right depends on where the viewer | |
| 16 | * comes from: | |
| 17 | * | |
| 18 | * - A page with a `<script server>` block that resolves the auth-token cookie | |
| 19 | * knows the viewer BEFORE paint, so it branches: ` | |