ReviewOS
@if (!pullRequest)

No such pull request

loghqorg/loghq has no pull request numbered 17.

@else

also looking at this

loghqorg/loghq

Promote main to production: signed-out chrome and the subscriber tables

#17
Merged glennmichael123 wants to merge main into production
54 files +4252 -1353
.github/workflows/ci.ymlmodified+9-3
Changes to .github/workflows/ci.yml
@@ -96,9 +96,15 @@ jobs:
9696 # the count down, then delete `continue-on-error` and add the grep that
9797 # fails on anything but "0 error(s), 0 warning(s)".
9898 #
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.
102108 - name: Typecheck .stx (advisory)
103109 continue-on-error: true
104110 run: bunx --bun stx typecheck 'resources/**/*.stx'
app/Models/Project.tsmodified+6-0
Changes to app/Models/Project.ts
@@ -27,6 +27,12 @@ export default defineModel({
2727 // Public ingest key: the SDK sends it as `X-LogHQ-Key` so reports can only
2828 // be written to a project that presents its key (revocable, not a secret).
2929 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() } },
3036 owner_id: { fillable: true, validation: { rule: schema.number().optional() } },
3137 is_active: { fillable: true, validation: { rule: schema.boolean().optional() }, factory: () => true },
3238 },
app/Support/read-key.tsadded+104-0
Changes to app/Support/read-key.ts
@@ -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 */
26const PREFIX = 'lhq_read_'
27const BODY = /^[0-9a-f]{64}$/
28
29export 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. */
35export 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 */
47export 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
56export interface ReadKeyProject {
57 id: string
58 read_key?: string | null
59 is_active?: boolean | number | null
60}
61
62export 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 */
73export 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 */
95export const LAST_USED_RESOLUTION_MS = 60_000
96
97export 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}
app/Support/request-auth.tsmodified+85-0
Changes to app/Support/request-auth.ts
@@ -14,6 +14,8 @@
1414 */
1515import { Auth } from '@stacksjs/auth'
1616import { config } from '@stacksjs/config'
17import { db } from '@stacksjs/database'
18import { authorizeRead, looksLikeReadKey, shouldRecordUse } from './read-key'
1719
1820export async function userFromRequest(request: any): Promise<any | null> {
1921 const authHeader = request.headers?.get?.('authorization') ?? ''
@@ -37,3 +39,86 @@ export async function userFromRequest(request: any): Promise<any | null> {
3739 return null
3840 }
3941}
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 */
50function 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
61export 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 */
79export 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. */
117async 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
Changes to bun.lock
@@ -19,9 +19,9 @@
1919 },
2020 },
2121 "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",
2525 },
2626 "packages": {
2727 "@bughq/sdk": ["@bughq/sdk@0.2.2", "", {}, "sha512-OySn397nSGHmFv4qu8/HUhX25sb+ORKCk94yOt/OR1vymScXPJPAA5eV9WcxrnzVUPvNOgEhp4lfEDm8MVS8Kw=="],
@@ -96,7 +96,7 @@
9696
9797 "@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=="],
9898
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=="],
100100
101101 "@stacksjs/composables": ["@stacksjs/composables@0.75.79", "", { "dependencies": { "@stacksjs/datetime": "0.75.79", "@stacksjs/stx": "^0.2.378" } }, "sha512-ZPVfcfLqgyexCYmyW7ignmeS+d6fH7S98gWU+0IGGT9NsAJ2tLZ3bafzKp1JinBQvDQxvpbBbrWFsc7n6ZXWbw=="],
102102
@@ -226,7 +226,7 @@
226226
227227 "@stacksjs/strings": ["@stacksjs/strings@0.75.79", "", {}, "sha512-u5WxVtwM48aMcgBJL3BTOoac/lyKhnfZGzqMjBh+wdR/GDUbwO++6uhBH8GXZwQttgh5OMhvTAgtkyLl4AITkA=="],
228228
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=="],
230230
231231 "@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=="],
232232
@@ -244,7 +244,7 @@
244244
245245 "@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=="],
246246
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=="],
248248
249249 "@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=="],
250250
@@ -396,7 +396,7 @@
396396
397397 "bun-plugin-dtsx": ["bun-plugin-dtsx@0.11.5", "", { "dependencies": { "@stacksjs/dtsx": "0.11.5" } }, "sha512-dva7CrHthEYAmnEQu8m0yvaP0joNS3zQQC4TSt7CS/dlEfUYVYU5jTqAB+Iseew4H2HceD5cy57XzpdpG7g9IA=="],
398398
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=="],
400400
401401 "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=="],
402402
@@ -444,7 +444,7 @@
444444
445445 "stripe": ["stripe@23.0.0", "", { "peerDependencies": { "@types/node": ">=20" }, "optionalPeers": ["@types/node"] }, "sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA=="],
446446
447 "stx-router": ["stx-router@0.2.401", "", {}, "sha512-WtdMe3rMnJUskdNsbRp5QAVdN8Gucuu7H5GaSReFyk2FMEzYw2HK0YlxruWpckt6y9ipenn+djgsaI9y69GOwQ=="],
447 "stx-router": ["stx-router@0.2.532", "", {}, "sha512-2aKVuabYvR1FXNb6huS2/CK02xyugE+xvbsULvb7EyH3LUgL7d7kBKIxpdiT4d+0H9EZ0uCpLLxnb5CPfwsRAQ=="],
448448
449449 "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=="],
450450
@@ -514,6 +514,8 @@
514514
515515 "@stacksjs/cli/@stacksjs/clapp": ["@stacksjs/clapp@0.2.16", "", {}, "sha512-g/5HUXGxD9QffyPKGdywZODte0DJYwzNhSmrPime1z7TLxWPodJ9IYfIwr2G6Wco+fAQISgaVQUtVf0pUNGV0w=="],
516516
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
517519 "@stacksjs/config/ts-pantry": ["ts-pantry@0.11.71", "", { "bin": { "ts-pantry": "./dist/bin/cli.js" } }, "sha512-0j1aDIztr9Bw2KVN03TPFu2jgwIlQnKNQtjMmrzT3uhWiQYIwKzchIuQMSfx2zHzMu05iyFCxp7MG3x6vMPw6w=="],
518520
519521 "@stacksjs/database/@stacksjs/ts-validation": ["@stacksjs/ts-validation@0.5.6", "", {}, "sha512-JUWD4ZfbhgIqXJMAaG4YAKUKIudr61Pa1yr0LwjxX5aJfBeN5vexN+iNacjZ1Vp+WTusIuebQnWroU0OKhA9ow=="],
@@ -540,9 +542,9 @@
540542
541543 "@stacksjs/stx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.16", "", {}, "sha512-g/5HUXGxD9QffyPKGdywZODte0DJYwzNhSmrPime1z7TLxWPodJ9IYfIwr2G6Wco+fAQISgaVQUtVf0pUNGV0w=="],
542544
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=="],
544546
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=="],
546548
547549 "@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
548550
@@ -558,7 +560,7 @@
558560
559561 "@stacksjs/ts-css/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
560562
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=="],
562564
563565 "@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=="],
564566
@@ -628,6 +630,8 @@
628630
629631 "@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=="],
630632
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
631635 "@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=="],
632636
633637 "@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 @@
664668
665669 "@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=="],
666670
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
667673 "@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=="],
668674
669675 "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 @@
688694
689695 "@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=="],
690696
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
691699 "@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=="],
692700
693701 "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 @@
704712
705713 "@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=="],
706714
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
707717 "@stacksjs/types/@stacksjs/bunpress/bunfig/@stacksjs/clarity/bunfig/@stacksjs/clapp": ["@stacksjs/clapp@0.2.12", "", {}, "sha512-EzCjl9cfKNMe7UPONHSSAffyfFVrbLzcEVx2i6HYLnJkcka5HFf1vPvOMkErCC10WDX+2Z1BOMTdsTXP1MqIbQ=="],
708718
709719 "@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
Changes to config/crosswind.ts
@@ -26,13 +26,32 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
2626 * HTML, not over globs, so a content list means nothing here.
2727 * output OVERRIDDEN to "". The CSS is injected as a <style> tag.
2828 * 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.
3655 *
3756 * Anything not on the surviving list is decoration. This file used to carry
3857 * `content`, `minify`, and `preflight: true` — and that last one is not even a
@@ -43,7 +62,1292 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
4362 *
4463 * @see https://github.com/cwcss/crosswind
4564 */
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 */
91const 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
242body {
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 */
367const 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 * ------------------------------------------------------------------------ */
442html {
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; }
569a.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; }
570a.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); }
576a.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); }
582details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); }
583summary { list-style: none; cursor: pointer; }
584summary::-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. */
620a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible {
621 outline: 2px solid var(--accent); outline-offset: 2px;
622}
623summary: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. */
638select.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 */
795const MARKETING = `
796html: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}
821html: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}
827html: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
834html:has(meta[name="stx-layout-group"][content="marketing"]), html:has(meta[name="stx-layout-group"][content="marketing"]) * { box-sizing: border-box; }
835html:has(meta[name="stx-layout-group"][content="marketing"]) { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
836html: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
843html:has(meta[name="stx-layout-group"][content="marketing"]) {
844a { 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; }
1003a.cell { cursor: pointer; }
1004a.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); }
1009a.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); }
1013a.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"; }
1017a.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 */
1213const 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,
1236select.panel:focus-visible,
1237input:focus-visible,
1238textarea:focus-visible,
1239summary: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
461340export 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 ],
471351 theme: {
481352 extend: {
491353 borderRadius: {
@@ -101,7 +1405,22 @@ export default {
1011405 'fg': 'var(--text)',
1021406 'fg-strong': 'var(--text)',
1031407 '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)',
1051424 'fg-subtle': 'var(--text-3)',
1061425 'line-strong': 'color-mix(in srgb, var(--text-3) 55%, var(--border))',
1071426 'line-hover': 'color-mix(in srgb, var(--text-2) 70%, var(--border))',
@@ -192,10 +1511,119 @@ export default {
1921511 + 'focus:outline-none focus:border-accent '
1931512 + 'focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_16%,transparent)]',
1941513
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',
1991627
2001628 'oauth-btn': 'bg-panel border border-line rounded-[10px] text-ink '
2011629 + '[transition:border-color_0.15s_ease,transform_0.12s_ease] '
@@ -219,6 +1647,22 @@ export default {
2191647 'err-box': 'bg-[color-mix(in_srgb,#ef4444_12%,transparent)] text-[#ef4444]',
2201648 'ok-box': 'bg-[color-mix(in_srgb,#22c55e_12%,transparent)] text-[#16a34a]',
2211649
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
2221666 // SiteFooter's repeated link and column heading. Four columns and ~28
2231667 // links, so these earn a name rather than being repeated on each element.
2241668 'footer-head': 'm-0 mb-[0.35rem] font-mono text-[0.7rem] font-semibold '
config/ui.tsmodified+5-12
Changes to config/ui.ts
@@ -211,23 +211,16 @@ export default {
211211 // /dashboard and /settings, which define their own conflicting versions of
212212 // the same class names. It lives in resources/layouts/marketing.stx
213213 // 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.
214219 link: [
215220 { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
216221 { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
217222 { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
218223 { 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' },
231224 ],
232225 },
233226 },
database/migrations/0000000004-create-users-table.sqlmodified+13-1
Changes to database/migrations/0000000004-create-users-table.sql
@@ -3,8 +3,20 @@
33-- one (stacksjs/stacks#2346: it either keeps the postgres files or writes 80
44-- migrations for 78 framework models). Fixed upstream, unreleased. Regenerate
55-- 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.
618CREATE TABLE IF NOT EXISTS "users" (
7 "id" INTEGER PRIMARY KEY AUTOINCREMENT,
19 "id" INTEGER PRIMARY KEY,
820 "name" varchar(100) not null,
921 "email" varchar(255) not null,
1022 "password" varchar(255) not null,
database/migrations/0000000005-create-subscriptions-table.sqlmodified+6-1
Changes to database/migrations/0000000005-create-subscriptions-table.sql
@@ -3,8 +3,13 @@
33-- one (stacksjs/stacks#2346: it either keeps the postgres files or writes 80
44-- migrations for 78 framework models). Fixed upstream, unreleased. Regenerate
55-- 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.
611CREATE TABLE IF NOT EXISTS "subscriptions" (
7 "id" INTEGER PRIMARY KEY AUTOINCREMENT,
12 "id" INTEGER PRIMARY KEY,
813 "type" text not null,
914 "plan" varchar(100),
1015 "provider_id" varchar(255) not null,
database/migrations/0000000008-create-email_suppressions-table.sqlmodified+6-1
Changes to database/migrations/0000000008-create-email_suppressions-table.sql
@@ -7,8 +7,13 @@
77-- (bounces, complaints, unsubscribes, manual blocks). The mail layer fails
88-- open when the table is missing on sqlite/mysql but not on postgres (its
99-- 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.
1015CREATE TABLE IF NOT EXISTS "email_suppressions" (
11 "id" INTEGER PRIMARY KEY AUTOINCREMENT,
16 "id" INTEGER PRIMARY KEY,
1217 "email" varchar(255) NOT NULL,
1318 "type" varchar(32) NOT NULL,
1419 "reason" varchar(255),
database/migrations/0000000009-alter-password_resets-add-expires_at.sqlmodified+6-1
Changes to database/migrations/0000000009-alter-password_resets-add-expires_at.sql
@@ -12,8 +12,13 @@
1212-- skips it and the ALTER below would hard-fail. Mirror the framework schema
1313-- (storage/framework/core/database/src/auth-tables.ts) so this migration stands
1414-- 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.
1520CREATE TABLE IF NOT EXISTS "password_resets" (
16 "id" INTEGER PRIMARY KEY AUTOINCREMENT,
21 "id" INTEGER PRIMARY KEY,
1722 "email" varchar(255) NOT NULL,
1823 "token" varchar(255) NOT NULL,
1924 "created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
database/migrations/0000000017-auto-misc.sqlmodified+46-21
Changes to database/migrations/0000000017-auto-misc.sql
@@ -16,24 +16,49 @@
1616--
1717-- See stacksjs/stacks#2346 for the related migration-generator problems.
1818-- @generated by `buddy migrate:regenerate` — edits will be overwritten
19PRAGMA foreign_keys=OFF;
20BEGIN;
21CREATE 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);
32INSERT 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";
33DROP TABLE "projects";
34ALTER TABLE "_qb_tmp_projects" RENAME TO "projects";
35CREATE UNIQUE INDEX IF NOT EXISTS "projects_ingest_key_unique" ON "projects" ("ingest_key");
36CREATE UNIQUE INDEX IF NOT EXISTS "projects_projects_ingest_key_unique" ON "projects" ("ingest_key");
37PRAGMA foreign_key_check;
38COMMIT;
39PRAGMA 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-- ---------------------------------------------------------------------------
64SELECT 1;
database/migrations/0000000024-alter-projects-read-key.sqladded+25-0
Changes to database/migrations/0000000024-alter-projects-read-key.sql
@@ -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.
23ALTER TABLE "projects" ADD COLUMN "read_key" TEXT;
24ALTER TABLE "projects" ADD COLUMN "read_key_last_used_at" TEXT;
25CREATE UNIQUE INDEX IF NOT EXISTS "projects_read_key_unique" ON "projects" ("read_key") WHERE "read_key" IS NOT NULL;
database/migrations/0000000025-create-subscriber-tables.sqladded+58-0
Changes to database/migrations/0000000025-create-subscriber-tables.sql
@@ -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
30CREATE 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
42CREATE UNIQUE INDEX IF NOT EXISTS "subscribers_subscribers_email_unique" ON "subscribers" ("email");
43CREATE 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`.
47CREATE 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
57CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_emails_subscriber_emails_uuid_unique" ON "subscriber_emails" ("uuid");
58CREATE INDEX IF NOT EXISTS "subscriber_emails_email" ON "subscriber_emails" ("email");
database/seeders/OwnerSeeder.tsadded+219-0
Changes to database/seeders/OwnerSeeder.ts
@@ -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 */
50import { db, Seeder } from '@stacksjs/database'
51import { env } from '@stacksjs/env'
52import { log } from '@stacksjs/logging'
53import { makeHash, verifyHash } from '@stacksjs/security'
54import { 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 */
61const DEV_EMAIL = 'owner@loghq.localhost'
62const DEV_PASSWORD = 'stacks-dev-only'
63const DEV_NAME = 'LogHQ Owner'
64const 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 */
77const LOCAL_ENVS = new Set(['development', 'dev', 'local', 'test', 'testing'])
78const 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 */
88async 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 */
105async 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
117export 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
218if (import.meta.main)
219 await new OwnerSeeder().run()
package.jsonmodified+3-3
Changes to package.json
@@ -87,9 +87,9 @@
8787 },
8888 "//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.",
8989 "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"
9393 },
9494 "devDependencies": {
9595 "@iconify-json/lucide": "^1.2.121",
public/app-chrome.cssdeleted+0-370
Changes to public/app-chrome.css
@@ -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 * ------------------------------------------------------------------------ */
99html {
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; }
198a.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); }
199a.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); }
205a.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); }
211details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); }
212summary { list-style: none; cursor: pointer; }
213summary::-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. */
249a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible {
250 outline: 2px solid var(--accent); outline-offset: 2px;
251}
252summary: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. */
267select.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
Changes to public/marketing.css
@@ -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; }
57html { background: var(--bg); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
58body {
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}
64a { 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; }
223a.cell { cursor: pointer; }
224a.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); }
229a.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); }
233a.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"; }
237a.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
Changes to public/tokens.css
@@ -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
187body {
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}
resources/assets/scripts/site-mode.jsmodified+32-0
Changes to resources/assets/scripts/site-mode.js
@@ -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
127(() => {
28 // stx-strict-ignore-next-line document.body
229 const body = document.body
330 if (!body || body.dataset.siteMode !== 'coming-soon')
431 return
@@ -10,22 +37,27 @@
1037 const token = body.dataset.comingSoonBypass || ''
1138 const expectedValue = token || '1'
1239 const cookieName = 'stacks_coming_soon_preview'
40 // stx-strict-ignore-next-line window.location
1341 const params = new URLSearchParams(window.location.search)
1442 const requestedPreview = params.get('preview') || params.get('bypass') || ''
1543
1644 if (requestedPreview === 'off') {
45 // stx-strict-ignore-next-line document.cookie
1746 document.cookie = `${cookieName}=; Path=/; Max-Age=0; SameSite=Lax`
1847 return
1948 }
2049
2150 if (requestedPreview && requestedPreview === expectedValue) {
51 // stx-strict-ignore-next-line document.cookie
2252 document.cookie = `${cookieName}=${encodeURIComponent(expectedValue)}; Path=/; Max-Age=2592000; SameSite=Lax`
2353 body.classList.add('site-mode-unlocked')
54 // stx-strict-ignore-next-line window.history window.location
2455 window.history.replaceState({}, '', `${window.location.pathname}${window.location.hash}`)
2556 return
2657 }
2758
2859 const cookies = Object.fromEntries(
60 // stx-strict-ignore-next-line document.cookie
2961 document.cookie
3062 .split(';')
3163 .map(cookie => cookie.trim())
resources/layouts/marketing.stxmodified+4-1
Changes to resources/layouts/marketing.stx
@@ -62,7 +62,10 @@
6262 rules. The shared fonts, charset, viewport and favicon DO live in app.head —
6363 they are genuinely site-wide, and injectConfigHeadTags dedupes by exact href
6464 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. --}}
6669{{-- No @stack('styles') here, following the same rule default.stx states: add a
6770 slot when a page pushes to it, not before. Nine pages used to carry an empty
6871 `` pair — index and all eight compare pages —
resources/partials/Breadcrumbs.stxmodified+25-46
Changes to resources/partials/Breadcrumbs.stx
@@ -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>.
62
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.
910
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.
1414
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).
2221
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" />
resources/partials/SiteFooter.stxmodified+16-6
Changes to resources/partials/SiteFooter.stx
@@ -14,12 +14,22 @@ const footerYear = new Date().getFullYear()
1414
1515<footer class="footer shell">
1616 {{-- The two source rules were `@media (max-width: 900px)` and
17 `(max-width: 560px)`. Crosswind emits nothing for a max-width
18 variant, so this is the same design stated mobile-first: one column
19 by default, two from bp-sm (561px), the 1.4fr lead column from bp-md
20 (901px). The breakpoints are declared in config/crosswind.ts and sit
21 one pixel above the max-widths they replace, which is what keeps the
22 boundaries identical. --}}
17 `(max-width: 560px)`, restated mobile-first: one column by default, two
18 from bp-sm (561px), the 1.4fr lead column from bp-md (901px). The
19 breakpoints are declared in config/crosswind.ts and sit one pixel above
20 the max-widths they replace, which is what keeps the boundaries
21 identical.
22
23 This used to say crosswind emits nothing for a max-width variant, which
24 is not true and misled a reader into thinking the direction was
25 unavailable. Measured on 0.3.8, which this app runs: `max-md:` and
26 `max-sm:` both emit their `@media (max-width: …)` correctly. What emits
27 nothing is the ARBITRARY form, `max-[560px]:` -- and 560 and 900 are not
28 on any scale, so that is the one this footer would have needed.
29
30 It stays mobile-first regardless. Min-width is the better default for a
31 layout that grows, and the named breakpoints say what they mean where a
32 pair of arbitrary max-widths did not. --}}
2333 <div class="grid gap-x-8 gap-y-7 grid-cols-1 bp-md:gap-x-10 bp-md:gap-y-8 bp-md:grid-cols-[1.4fr_1fr_1fr_1fr] bp-sm:grid-cols-2 pb-9 pt-4 footer-cols">
2434 <div class="flex flex-col gap-[0.6rem] footer-col">
2535 <h4 class="footer-head">Product</h4>
resources/views/account.stxmodified+25-6
Changes to resources/views/account.stx
@@ -30,6 +30,11 @@ function fmtDate(v: string | null | undefined): string {
3030
3131// Derived reads. Plain functions rather than computed(): each is called from a
3232// 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.
36const signedIn = (): boolean => loaded() && !!user()
37const signedOut = (): boolean => loaded() && !user()
3338const displayName = (): string => user()?.name || 'Your account'
3439const email = (): string => user()?.email || ''
3540// A remote avatar can 404 — a revoked GitHub account, a Gravatar that was
@@ -110,8 +115,13 @@ onMount(() => {
110115 <header class="flex items-center justify-between mx-auto px-5 md:px-6 h-16 max-w-2xl">
111116 <StxLink to="/" class="text-lg wordmark">loghq</StxLink>
112117 <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>
115125 </div>
116126 </header>
117127
@@ -123,7 +133,16 @@ onMount(() => {
123133 <div class="mx-auto px-5 py-8 md:px-6 max-w-2xl">
124134 <h1 class="mb-6 text-2xl wordmark">Account</h1>
125135
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()">
127146 <div class="flex gap-4 items-center">
128147 {{-- The initials block is the fallback for BOTH "no avatar" and "the
129148 avatar failed to load", so the two :show conditions are exact
@@ -143,7 +162,7 @@ onMount(() => {
143162 {{-- Skeleton and value are separate elements rather than one element
144163 swapping classes: :class works but logs on every bind, and the
145164 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>
147166 <div class="font-semibold text-lg" :show="loaded()" :text="displayName()"></div>
148167 <div class="mt-1.5 text-muted text-sm" :text="email()"></div>
149168 </div>
@@ -151,7 +170,7 @@ onMount(() => {
151170 </div>
152171 </section>
153172
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()">
155174 <div class="flex items-center justify-between px-6 py-4">
156175 <div>
157176 <div class="font-mono text-subtle text-xs tracking-wide uppercase">Plan</div>
@@ -183,7 +202,7 @@ onMount(() => {
183202 </div>
184203 </section>
185204
186 <p class="text-sm text-subtle">
205 <p class="text-sm text-subtle" :show="!signedOut()">
187206 Signed in with a private token. <StxLink to="/" class="text-accent">Back to site</StxLink>
188207 </p>
189208 </div>
resources/views/analytics.stxmodified+10-132
Changes to resources/views/analytics.stx
@@ -240,141 +240,19 @@ onMounted(() => {
240240@section('content')
241241
242242
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,
269select.panel:focus-visible,
270input:focus-visible,
271textarea:focus-visible,
272summary: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>
372243<div class="grain" aria-hidden="true"></div>
373244
374245<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
378256
379257 <div class="mx-auto max-w-5xl">
380258
resources/views/dashboard.stxmodified+35-15
Changes to resources/views/dashboard.stx
@@ -647,9 +647,16 @@ onMount(() => {
647647 {{-- Fixed-position chrome. Inside the page container, not document.body, so an
648648 SPA view swap takes it with the rest of the page. Position is fixed, so
649649 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
653660
654661 <div class="mx-auto max-w-5xl">
655662
@@ -674,7 +681,7 @@ onMount(() => {
674681 @if (user && projectsUnavailable)
675682 <div class="py-24 text-center">
676683 <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>
678685 </div>
679686 @endif
680687
@@ -720,17 +727,30 @@ onMount(() => {
720727 <Icon name="lucide:moon" size="17" class="h-[17px] w-[17px] ic-moon" />
721728 </button>
722729 {{-- 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.
734754
735755 The span is inline-flex so it behaves as the same flex item the
736756 anchor used to be, and the hover area is unchanged. --}}
resources/views/docs.stxmodified+3-3
Changes to resources/views/docs.stx
@@ -29,7 +29,7 @@ const crumbs = [
2929<section class="feat-hero shell">
3030 <span class="kicker">Docs</span>
3131 <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>
3333 <div class="hero-actions">
3434 <StxLink class="btn primary" to="/register">Create a project</StxLink>
3535 <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 = [
4646 <span class="flow-num">01</span>
4747 <div>
4848 <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>
5050 </div>
5151 </div>
5252 <div class="flow-step">
@@ -69,7 +69,7 @@ const crumbs = [
6969<section class="reveal section shell" aria-label="Send your first log">
7070 <div class="max-w-[700px] code">
7171 <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
7373curl -X POST https://loghq.org/logs \
7474 -H 'Content-Type: application/json' \
7575 -H 'X-LogHQ-Key: loghq_your_project_key' \
resources/views/fix.stxmodified+2-2
Changes to resources/views/fix.stx
@@ -217,7 +217,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
217217 </section>
218218
219219 @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>
221221 @endif
222222
223223 @if (!enabled)
@@ -232,7 +232,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
232232 @endif
233233
234234 @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">
236236 The last analysis stopped. {{ run.error || 'No further detail was recorded.' }}
237237 </div>
238238 @endif
resources/views/forgot-password.stxmodified+2-2
Changes to resources/views/forgot-password.stx
@@ -53,7 +53,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) =>
5353 </div>
5454
5555 @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">
5757 If an account exists for that email, a reset link is on its way. It expires in 60 minutes.
5858 </div>
5959 <p class="mt-6 text-center text-sm text-subtle">
@@ -63,7 +63,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) =>
6363 {{-- Posts to itself; the action is in the server block. --}}
6464 <form action="/forgot-password" method="POST" class="space-y-4">
6565 @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>
6767 @endif
6868
6969 <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>
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -203,7 +203,7 @@ async function subscribe() {
203203 required
204204 x-model="email"
205205 >
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>
207207 </form>
208208 <p class="sub-status" role="status" aria-live="polite" :data-state="failed() ? 'error' : ''" :text="status()"></p>
209209 </div>
resources/views/login.stxmodified+4-4
Changes to resources/views/login.stx
@@ -139,7 +139,7 @@ async function onVerify(e: Event): Promise<void> {
139139
140140 {{-- The shared error box for both steps. `:text` overwrites the (empty)
141141 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>
143143
144144 {{-- Step 1: credentials. A same-origin fetch, not a native POST — the
145145 submit is intercepted so the browser never navigates to /login itself. --}}
@@ -163,7 +163,7 @@ async function onVerify(e: Event): Promise<void> {
163163 <span>Keep me signed in for 30 days</span>
164164 </label>
165165
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>
167167 </form>
168168
169169 {{-- Step 2: TOTP, shown only when the account has 2FA enabled. --}}
@@ -173,11 +173,11 @@ async function onVerify(e: Event): Promise<void> {
173173 <label for="login-code" class="block mb-1.5 font-medium text-muted text-xs">Authentication code</label>
174174 <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' : ''">
175175 </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>
177177 </form>
178178
179179 @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>
181181 @endif
182182
183183 @if (anyOauth)
resources/views/pricing.stxmodified+1-1
Changes to resources/views/pricing.stx
@@ -125,7 +125,7 @@ async function upgrade(): Promise<void> {
125125 <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Full retention</li>
126126 <li class="flex gap-2 items-center"><span class="text-accent">✓</span> Priority support</li>
127127 </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>
129129 @if (err())
130130 <p class="mt-3 text-[#ef4444] text-center text-xs">{{ err() }}</p>
131131 @endif
resources/views/projects/index.stxmodified+17-4
Changes to resources/views/projects/index.stx
@@ -120,15 +120,28 @@ function logout(): void {
120120 <header class="flex gap-4 items-center justify-between mb-9">
121121 <div>
122122 <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
124131 </div>
125132 <div class="flex gap-2.5 items-center">
126133 <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">
127134 <Icon name="lucide:sun" size="18" class="h-[18px] w-[18px] ic-sun" />
128135 <Icon name="lucide:moon" size="18" class="h-[18px] w-[18px] ic-moon" />
129136 </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
132145 </div>
133146 </header>
134147
@@ -164,7 +177,7 @@ function logout(): void {
164177 @if (projectsUnavailable)
165178 <div class="px-5 py-16 text-center bg-panel border border-line rounded-xl">
166179 <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>
168181 </div>
169182 @else
170183 <EmptyState title="No apps yet" description="Create your first app to start capturing errors.">
resources/views/projects/new.stxmodified+1-1
Changes to resources/views/projects/new.stx
@@ -106,7 +106,7 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
106106 {{-- Posts to itself; the action is in the server block. --}}
107107 <form action="/projects/new" method="POST" class="space-y-4">
108108 @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>
110110 @endif
111111
112112 <div>
resources/views/register.stxmodified+3-3
Changes to resources/views/register.stx
@@ -92,10 +92,10 @@ async function onRegister(e: Event): Promise<void> {
9292 </div>
9393
9494 @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>
9696 @endif
9797
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>
9999
100100 {{-- A same-origin fetch, not a native POST — the submit is intercepted. --}}
101101 <form action="/api/auth/register" method="POST" @submit="onRegister" class="space-y-4">
@@ -112,7 +112,7 @@ async function onRegister(e: Event): Promise<void> {
112112 <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' : ''">
113113 </div>
114114
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>
116116 </form>
117117
118118 @if (anyOauth)
resources/views/reset-password.stxmodified+2-2
Changes to resources/views/reset-password.stx
@@ -72,7 +72,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) =>
7272 </div>
7373
7474 @if (done)
75 <div class="px-3 py-3 text-sm rounded-lg ok-box">
75 <div class="py-3 notice-box ok-box">
7676 Your password has been reset. You have been signed out everywhere; sign in with the new password.
7777 </div>
7878 <p class="mt-6 text-center text-sm text-subtle">
@@ -85,7 +85,7 @@ const action = async ({ form }: { form: Record<string, string | string[]> }) =>
8585 <input type="hidden" name="token" value="">
8686 <input type="hidden" name="email" value="">
8787 @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>
8989 @endif
9090
9191 <div>
resources/views/settings.stxmodified+201-30
Changes to resources/views/settings.stx
@@ -116,7 +116,7 @@ if (user) {
116116 try {
117117 // Owned projects only — settings is the owner's admin surface.
118118 __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',
120120 [Number(user.id)],
121121 )) ?? []
122122 if (__projectRows.length === 0)
@@ -158,8 +158,8 @@ if (user) {
158158
159159// Template-facing projections. `ingest_key` is dropped here and nowhere else:
160160// the switcher needs a project's id, name and state, never its credential.
161const projects = __projectRows.map(({ ingest_key: _ignored, ...p }) => p)
162const activeProject = __activeRow ? (({ ingest_key: _ignored, ...p }) => p)(__activeRow) : null
161const projects = __projectRows.map(({ ingest_key: _ignored, read_key: _secret, ...p }) => p)
162const activeProject = __activeRow ? (({ ingest_key: _ignored, read_key: _secret, ...p }) => p)(__activeRow) : null
163163const projectId = activeProject?.id || null
164164
165165// 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
253253// named value, so publishing it stays a decision rather than a side effect.
254254const keyFull = __activeRow ? String(__activeRow.ingest_key || '') : ''
255255const 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.
260const hasReadKeyInitial = !!(__activeRow && __activeRow.read_key)
261const readKeyUsedInitial = __activeRow && __activeRow.read_key_last_used_at ? String(__activeRow.read_key_last_used_at) : ''
256262
257263// Framework-specific install snippet, keyed off the project's platform. Key-only
258264// (Flare-style): the ingest key alone identifies the project — no project id, no DSN.
@@ -539,6 +545,8 @@ defineClientPayload({
539545 codeText: guide.code,
540546 initialKeyFull: keyFull,
541547 initialKeyMask: keyMask,
548 hasReadKeyInitial,
549 readKeyUsedInitial,
542550})
543551
544552useSeoMeta({
@@ -714,6 +722,14 @@ const repo = state<any>(repository || null)
714722const repoRef = state('')
715723const repoToken = state('')
716724const 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 */
732const repoAction = state('')
717733const repoNote = state('')
718734const repoNoteOk = state(true)
719735
@@ -725,10 +741,12 @@ async function connectRepo(): Promise<void> {
725741 return
726742 }
727743 repoBusy.set(true)
744 repoAction.set('connect')
728745 try {
729746 const res = await apiFetch(`${projUrl}/repository`, { method: 'POST', body: JSON.stringify({ repository: ref, token }) })
730747 const body = await readJson(res)
731748 repoBusy.set(false)
749 repoAction.set('')
732750 if (!res.ok) {
733751 note(repoNote, repoNoteOk, body.error || 'Could not connect.', false)
734752 return
@@ -741,16 +759,19 @@ async function connectRepo(): Promise<void> {
741759 }
742760 catch {
743761 repoBusy.set(false)
762 repoAction.set('')
744763 note(repoNote, repoNoteOk, 'Network error. Try again.', false)
745764 }
746765}
747766
748767async function verifyRepo(): Promise<void> {
749768 repoBusy.set(true)
769 repoAction.set('verify')
750770 try {
751771 const res = await apiFetch(`${projUrl}/repository/verify`, { method: 'POST' })
752772 const body = await readJson(res)
753773 repoBusy.set(false)
774 repoAction.set('')
754775 if (!res.ok) {
755776 note(repoNote, repoNoteOk, body.error || 'Verification failed.', false)
756777 return
@@ -760,6 +781,7 @@ async function verifyRepo(): Promise<void> {
760781 }
761782 catch {
762783 repoBusy.set(false)
784 repoAction.set('')
763785 note(repoNote, repoNoteOk, 'Network error. Try again.', false)
764786 }
765787}
@@ -771,9 +793,11 @@ async function disconnectRepo(): Promise<void> {
771793 if (!(await Promise.resolve(ask)))
772794 return
773795 repoBusy.set(true)
796 repoAction.set('disconnect')
774797 try {
775798 const res = await apiFetch(`${projUrl}/repository`, { method: 'DELETE' })
776799 repoBusy.set(false)
800 repoAction.set('')
777801 if (!res.ok) {
778802 const body = await readJson(res)
779803 note(repoNote, repoNoteOk, body.error || 'Could not disconnect.', false)
@@ -784,6 +808,7 @@ async function disconnectRepo(): Promise<void> {
784808 }
785809 catch {
786810 repoBusy.set(false)
811 repoAction.set('')
787812 note(repoNote, repoNoteOk, 'Network error. Try again.', false)
788813 }
789814}
@@ -828,6 +853,88 @@ async function rotateKey(): Promise<void> {
828853 }
829854}
830855
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.
861const hasReadKey = state(hasReadKeyInitial)
862const readKeyUsed = state(readKeyUsedInitial)
863const readKeyOnce = state('')
864const readKeyBusy = state(false)
865const readKeyNote = state('')
866const readKeyNoteOk = state(true)
867const copiedReadKey = state(false)
868
869/** "never", or the stamp as the browser's locale writes it. */
870const 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
878function copyReadKey(): void {
879 void copyText(readKeyOnce(), copiedReadKey)
880}
881
882async 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
912async 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
831938// ---- members and invites ----
832939const inviteEmail = state('')
833940const inviting = state(false)
@@ -1225,7 +1332,7 @@ onMount(() => {
12251332 @if (user && projectsUnavailable)
12261333 <div class="py-24 text-center">
12271334 <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>
12291336 </div>
12301337 @endif
12311338
@@ -1254,6 +1361,7 @@ onMount(() => {
12541361 <nav class="flex flex-wrap gap-2 items-center mb-8 text-sm">
12551362 <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>
12561363 <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>
12571365 <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>
12581366 <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>
12591367 <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(() => {
12761384 @if (guide.install)
12771385 <div class="relative mb-2">
12781386 <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>
12801388 </div>
12811389 @endif
12821390 <div class="relative">
12831391 <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>
12851393 </div>
12861394 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
12871395 @if (guide.native)
@@ -1301,21 +1409,60 @@ onMount(() => {
13011409 <div class="px-5 py-5 app-panel">
13021410 <p class="mb-4 text-sm text-subtle">
13031411 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.
13051413 </p>
13061414 <div class="flex flex-wrap gap-2 items-center">
13071415 <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>
13111419 </div>
13121420 <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>).
13141422 </p>
13151423 <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>
13161424 </div>
13171425 </section>
13181426
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
13191466 {{-- ========================== REPOSITORY ========================== --}}
13201467 <section id="repository" class="mb-10 scroll-mt-[84px]">
13211468 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Repository</div>
@@ -1330,8 +1477,8 @@ onMount(() => {
13301477 <div :show="!!repo()">
13311478 <div class="flex flex-wrap gap-2 items-center mb-3.5">
13321479 <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>
13351482 </div>
13361483 <dl class="grid gap-y-1.5 grid-cols-2 sm:grid-cols-3 text-xs">
13371484 <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(() => {
13481495 <label class="block mb-1.5 font-medium text-muted text-xs" for="repo-token">GitHub token</label>
13491496 <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">
13501497
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>
13521499
13531500 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
13541501 Use a <span class="text-muted">fine-grained</span> personal access token scoped to this one
@@ -1384,7 +1531,31 @@ onMount(() => {
13841531 <div class="mt-0.5 text-subtle text-xs">Can read the log stream</div>
13851532 </div>
13861533 <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>
13881559 </div>
13891560 @endforeach
13901561 @foreach (inviteView as i)
@@ -1392,11 +1563,11 @@ onMount(() => {
13921563 <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>
13931564 <div class="flex-1 min-w-0">
13941565 <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>
13961567 </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>
14001571 </div>
14011572 @endforeach
14021573 </div>
@@ -1404,7 +1575,7 @@ onMount(() => {
14041575 <div class="mb-3 font-semibold text-sm">Invite a teammate</div>
14051576 <div class="flex flex-wrap gap-2 items-center">
14061577 <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>
14081579 </div>
14091580 <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>
14101581 <p class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1417,7 +1588,7 @@ onMount(() => {
14171588 <section id="alerts" class="mb-10 scroll-mt-[84px]">
14181589 <div class="mb-3 font-semibold text-[13px] text-subtle tracking-[0.04em] uppercase">Alerts</div>
14191590 <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:
14211592 channels you add here are saved and testable, but loghq won't fire on its own until log-based alert rules ship.
14221593 </p>
14231594 <div class="flex flex-col gap-2.5 mb-4">
@@ -1435,7 +1606,7 @@ onMount(() => {
14351606 <div class="font-medium text-sm truncate">{{ c.label }}</div>
14361607 <div class="mt-0.5 font-mono text-subtle text-xs truncate">{{ c.mask }}</div>
14371608 </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>
14391610 {{-- A real <button>, not a <div role="switch">. As a div this had
14401611 no tabindex, no key handler and no accessible name: measured
14411612 in a browser it reported focusable:false and ariaLabel:null,
@@ -1452,7 +1623,7 @@ onMount(() => {
14521623 match the component instead of the product. The a11y gap was
14531624 the defect; the platform element closes it. --}}
14541625 <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>
14561627 </div>
14571628 @empty
14581629 <EmptyState title="No chat alerts yet" description="Add a Slack or Discord webhook below to start getting pinged." />
@@ -1465,9 +1636,9 @@ onMount(() => {
14651636 <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>
14661637 </div>
14671638 <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">
14691640 <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>
14711642 <span class="text-ok text-xs" :show="!!whNote()" :class="whNoteOk() ? 'text-xs text-ok' : 'text-xs text-accent'">{{ whNote() }}</span>
14721643 </div>
14731644 <p :text="whHelp()" class="mt-3.5 leading-relaxed text-subtle text-xs">
@@ -1557,10 +1728,10 @@ onMount(() => {
15571728 <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>
15581729 @else
15591730 <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>
15611732 @endif
15621733 </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>
15641735 </div>
15651736 <div class="h-px bg-[color-mix(in_srgb,var(--warn)_20%,var(--border))]"></div>
15661737 {{-- Delete --}}
@@ -1569,7 +1740,7 @@ onMount(() => {
15691740 <div class="mb-0.5 font-semibold text-sm">Delete this project</div>
15701741 <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>
15711742 </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>
15731744 </div>
15741745 </div>
15751746 </section>
@@ -1592,8 +1763,8 @@ onMount(() => {
15921763 <label class="block mb-1.5 text-subtle text-xs">Type <span class="font-mono text-muted">{{ projectName }}</span> to confirm</label>
15931764 <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">
15941765 <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>
15971768 </div>
15981769 <span class="mt-2.5 text-accent text-xs" :show="!!modalNote()">{{ modalNote() }}</span>
15991770 </div>
routes/logs.tsmodified+15-12
Changes to routes/logs.ts
@@ -15,9 +15,9 @@ import { rateLimit } from '../app/Errors/limits'
1515import { extractEntries, LEVELS, MAX_BATCH, MAX_CORRELATION, normalizeBatch } from '../app/Logs/normalize'
1616// The owner/member predicate lives in one file so these routes and the pages
1717// that render the same data cannot drift on it. See app/Support/access.ts.
18import { ownsLog, ownsProject } from '../app/Support/access'
18import { ownsProject } from '../app/Support/access'
1919import { healthResponse } from '../app/Support/health'
20import { userFromRequest } from '../app/Support/request-auth'
20import { projectReadAccess } from '../app/Support/request-auth'
2121
2222// Ingest abuse bounds. The public key gate is not enough on its own — a script
2323// 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
137137
138138route.get('/api/projects/{projectId}/logs', async (request: any) => {
139139 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)
145144
146145 const q = request.query ?? {}
147146 const where: string[] = ['project_id = $1']
@@ -207,12 +206,16 @@ route.get('/api/projects/{projectId}/logs', async (request: any) => {
207206
208207route.get('/api/logs/{logId}', async (request: any) => {
209208 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.
215215 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)
216219 if (!entry)
217220 return json({ error: 'not found' }, 404)
218221 return json({ entry })
routes/projects.tsmodified+42-0
Changes to routes/projects.ts
@@ -17,6 +17,7 @@ import { log } from '@stacksjs/logging'
1717// Shared with the page action in resources/views/projects/new.stx, so the two
1818// callers cannot drift on how an id or an ingest key is minted.
1919import { newIngestKey, newProjectId } from '../app/Support/projects'
20import { newReadKey } from '../app/Support/read-key'
2021import { utcNow } from '../app/Support/time'
2122import { GitHubRequestError, verifyAccess } from '../app/Fix/github'
2223import {
@@ -114,6 +115,9 @@ route.get('/api/projects', async (request: any) => {
114115 return json({ error: 'unauthorized' }, 401)
115116 const rows = await db.unsafe(
116117 `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,
117121 (p.owner_id = $1) AS is_owner
118122 FROM projects p
119123 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) => {
183187 return json({ ingest_key: key })
184188}).skipCsrf()
185189
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.
198route.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.
216route.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
186228// Delete a project and everything under it. Owner-only. Removes children first
187229// to satisfy the foreign keys (log entries/channels/members/invites -> project),
188230// then the project row.
scripts/smoke-deployment.tsmodified+86-6
Changes to scripts/smoke-deployment.ts
@@ -4,15 +4,17 @@ import { resolve } from 'node:path'
44interface SmokeContract {
55 domain: string
66 homeText: string
7 /** Whether POST /api/email/subscribe is registered in that app. */
8 subscribe: boolean
79}
810
911const 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 },
1618}
1719
1820const projectRoot = resolve(import.meta.dir, '..')
@@ -89,6 +91,84 @@ for (const [label, body] of [['homepage', home], ['login', login]] as const) {
8991 }
9092}
9193
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 */
123const SMOKE_SUBSCRIBER = 'smoke-probe@example.invalid'
124
125async 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
170if (contract.subscribe) await assertSubscribeWorks()
171
92172const robots = await readWithRetry('/robots.txt')
93173if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
94174if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
scripts/validate-ui.tsmodified+138-5
Changes to scripts/validate-ui.ts
@@ -1,14 +1,14 @@
1import { readdirSync, readFileSync, statSync } from 'node:fs'
1import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22import { join, relative, resolve } from 'node:path'
33
44const root = process.cwd()
55const resources = resolve(root, 'resources')
66const issues = new Set<string>()
77
8function filesIn(directory: string): string[] {
8function filesIn(directory: string, pattern = /\.(?:stx|html|css)$/): string[] {
99 return readdirSync(directory).flatMap((name) => {
1010 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] : []
1212 })
1313}
1414
@@ -35,7 +35,21 @@ function report(file: string, source: string, index: number, message: string): v
3535
3636const files = filesIn(resources)
3737const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file))
38const 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 */
48const cssConfig = resolve(root, 'config/crosswind.ts')
49if (!existsSync(cssConfig))
50 throw new Error(`validate-ui: expected the CSS config at ${relative(root, cssConfig)}`)
51
52const combined = [...files, cssConfig].map(file => readFileSync(file, 'utf8')).join('\n')
3953
4054for (const file of markupFiles) {
4155 const original = readFileSync(file, 'utf8')
@@ -78,9 +92,128 @@ for (const file of markupFiles) {
7892 }
7993}
8094
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 */
120for (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 */
189const shipsOwnStylesheet = (file: string): boolean =>
190 files.some(other => other.endsWith('.css') && other.slice(0, other.lastIndexOf('/')) === file.slice(0, file.lastIndexOf('/')))
191
192for (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
81208if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
82209 issues.add('resources: no visible keyboard focus treatment found')
83if (!/(?:@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 */
216if (!/(?:@media|(?:^|[\s"'`:])(?:sm|md|lg|xl):)/m.test(combined))
84217 issues.add('resources: no responsive breakpoint treatment found')
85218
86219console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`)
scripts/validate-visual.tsmodified+62-3
Changes to scripts/validate-visual.ts
@@ -70,6 +70,48 @@ async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise
7070 return value
7171}
7272
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 */
87async 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
73115class Cdp {
74116 private id = 0
75117 private pending = new Map<number, Pending>()
@@ -237,8 +279,9 @@ async function main(): Promise<void> {
237279 },
238280 })
239281 const profile = mkdtempSync(join(tmpdir(), 'hq-visual-'))
282 const chromeBinary = chromePath()
240283 const chrome = Bun.spawn([
241 chromePath(),
284 chromeBinary,
242285 '--headless=new',
243286 '--remote-debugging-port=0',
244287 `--user-data-dir=${profile}`,
@@ -255,13 +298,28 @@ async function main(): Promise<void> {
255298 '--disable-dev-shm-usage',
256299 '--disable-gpu',
257300 '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 })()
259317
260318 const failures: string[] = []
261319 let screenshots = 0
262320 try {
263321 const portFile = join(profile, 'DevToolsActivePort')
264 await waitFor(() => existsSync(portFile), 10_000)
322 await waitForDevToolsPort(portFile, chrome, () => chromeStderr, chromeBinary)
265323 const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
266324 const targets = await waitFor(async () => {
267325 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> {
321379 finally {
322380 chrome.kill()
323381 await chrome.exited
382 await drainingStderr
324383 server.stop(true)
325384 rmSync(profile, { force: true, recursive: true })
326385 }
tests/unit/auth-chrome.test.tsadded+141-0
Changes to tests/unit/auth-chrome.test.ts
@@ -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: `