diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a836c8..ea70eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - run: bun install --frozen-lockfile # Build first: plugins resolve @noctcore/eslint-utils via its emitted # dist/*.d.ts, so typecheck needs the build to have run. @@ -41,7 +41,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - run: bun install --frozen-lockfile # Tests read built dist/ too: plugins import @noctcore/eslint-utils, and # lint-meta-rules runs against the workspace plugins' builds. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e3dcbb2..4838f43 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,7 +44,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 # Astro 7 needs Node 22.12 or newer; the runner image ships an older one. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cef31db..d7af93e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 # Node 24 ships npm 11. Publishing uses npm trusted publishing (OIDC) only, # which needs npm 11.5.1 or later; there is no npm token to fall back to. - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c52d1e..bb5a142 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ exactly what is needed to act. ## Setup and the commands that matter -Requirements: [Bun](https://bun.sh) 1.3.14 (`packageManager` in the root `package.json`) and +Requirements: [Bun](https://bun.sh) 1.4.2 (`packageManager` in the root `package.json`) and Node 22 or newer (`engines`; the docs site needs 22.12 or newer). ```sh @@ -90,7 +90,9 @@ and each package's `recommended` preset: `lint-meta-rules` gets a table of rule id, factory, entry point and category instead; - a one-line status header in every `docs/rules/.md`, right after the title and blockquote summary, between `` and ``. - The site page drops it and shows its own metadata line. + The site page drops it and shows the same facts as its own facts strip, built by `factsStrip` in + `site/scripts/sync.ts` (an ESLint rule: package, preset, autofix, suggestions, options, type + information; a lint-meta rule: its package, factory, entry point and category). Never edit between the markers by hand. Run the command after adding a rule, changing a description, or moving a rule in or out of the preset, and commit what it writes. Running it twice diff --git a/bun.lock b/bun.lock index 6c5ecf8..121a294 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "packages/eslint-plugin-architecture": { "name": "@noctcore/eslint-plugin-architecture", - "version": "0.3.2", + "version": "0.3.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -33,7 +33,7 @@ }, "packages/eslint-plugin-async-safety": { "name": "@noctcore/eslint-plugin-async-safety", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -54,7 +54,7 @@ }, "packages/eslint-plugin-code-quality": { "name": "@noctcore/eslint-plugin-code-quality", - "version": "0.3.0", + "version": "0.3.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -75,7 +75,7 @@ }, "packages/eslint-plugin-contracts": { "name": "@noctcore/eslint-plugin-contracts", - "version": "0.7.0", + "version": "0.7.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -96,7 +96,7 @@ }, "packages/eslint-plugin-llm": { "name": "@noctcore/eslint-plugin-llm", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -117,7 +117,7 @@ }, "packages/eslint-plugin-monorepo": { "name": "@noctcore/eslint-plugin-monorepo", - "version": "0.2.2", + "version": "0.2.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -138,7 +138,7 @@ }, "packages/eslint-plugin-observability": { "name": "@noctcore/eslint-plugin-observability", - "version": "0.3.2", + "version": "0.3.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -159,7 +159,7 @@ }, "packages/eslint-plugin-prisma": { "name": "@noctcore/eslint-plugin-prisma", - "version": "0.5.0", + "version": "0.5.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -180,7 +180,7 @@ }, "packages/eslint-plugin-react": { "name": "@noctcore/eslint-plugin-react", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -201,7 +201,7 @@ }, "packages/eslint-plugin-rsc": { "name": "@noctcore/eslint-plugin-rsc", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -222,7 +222,7 @@ }, "packages/eslint-plugin-security": { "name": "@noctcore/eslint-plugin-security", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -261,7 +261,7 @@ }, "packages/eslint-utils": { "name": "@noctcore/eslint-utils", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@typescript-eslint/utils": "^8.61.1", }, @@ -277,7 +277,7 @@ }, "packages/lint-meta-rules": { "name": "@noctcore/lint-meta-rules", - "version": "0.5.0", + "version": "0.6.2", "dependencies": { "@noctcore/eslint-plugin-contracts": "^0.7.0", "@noctcore/eslint-plugin-prisma": "^0.5.0", diff --git a/package.json b/package.json index 6c8997d..7f5e889 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@noctcore/eslint-plugins", "private": true, "type": "module", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.2", "engines": { "node": ">=22" }, diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 770a2fa..14399be 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -20,9 +20,17 @@ export default defineConfig({ 'Focused ESLint plugins for architecture and correctness conventions generic linters cannot see: cross-file boundaries, IO contracts, and code that compiles but bites in production.', favicon: '/favicon.svg', logo: { src: './src/assets/mark.svg', alt: '' }, - customCss: ['./src/styles/theme.css'], + // The noctcore docs theme in its load order (tokens, pieces, preset), then + // this site's own rules. The Nocturne preset imports its fonts itself. + customCss: [ + './src/styles/noctcore/base.css', + './src/styles/noctcore/components.css', + './src/styles/noctcore/presets/nocturne.css', + './src/styles/site.css', + ], components: { - Head: './src/components/Head.astro', + SiteTitle: './src/components/SiteTitle.astro', + PageTitle: './src/components/PageTitle.astro', Footer: './src/components/Footer.astro', }, social: [ diff --git a/site/ec.config.mjs b/site/ec.config.mjs index e5d4deb..bde327f 100644 --- a/site/ec.config.mjs +++ b/site/ec.config.mjs @@ -1,9 +1,11 @@ import { defineEcConfig } from '@astrojs/starlight/expressive-code'; +import { noctcoreCodeConfig } from './src/styles/noctcore/expressive-code.mjs'; + /** * The rule docs mark every example ` ```ts bad ` or ` ```ts good `, and * scripts/sync.ts carries that through as `verdict=bad|good` fence meta. This - * plugin turns it into a class on the rendered block so theme.css can give the + * plugin turns it into a class on the rendered block so components.css can give the * two a different frame, label colour and marker. Rendered identically, a reader * cannot tell which snippet is the one not to write. */ @@ -24,5 +26,6 @@ function pluginVerdict() { } export default defineEcConfig({ + ...noctcoreCodeConfig('nocturne'), plugins: [pluginVerdict()], }); diff --git a/site/package.json b/site/package.json index ba28812..8ee6a0b 100644 --- a/site/package.json +++ b/site/package.json @@ -5,10 +5,10 @@ "type": "module", "scripts": { "sync": "bun scripts/sync.ts", - "dev": "bun run sync && astro dev", - "docs:build": "bun run sync && astro build && bun scripts/check-build.ts", - "preview": "astro preview", - "typecheck": "bun run sync && astro sync && tsc --noEmit", + "dev": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro dev", + "docs:build": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro build && bun scripts/check-build.ts", + "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", + "typecheck": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro sync && tsc --noEmit", "test": "bun test scripts" }, "dependencies": { diff --git a/site/scripts/deprecation.test.ts b/site/scripts/deprecation.test.ts index cbe3916..87d6948 100644 --- a/site/scripts/deprecation.test.ts +++ b/site/scripts/deprecation.test.ts @@ -173,6 +173,8 @@ describe('rendering a deprecated rule', () => { 'client. Use [`noctcore-fixture/new-rule`](../../fixture/new-rule/) instead.\n:::', ); expect(page).toContain(' badge:\n text: Deprecated\n variant: caution'); + expect(page).toContain('
Status
deprecated
'); + expect(page).not.toContain('nc-optin'); expect(page).not.toContain(''); }); @@ -181,6 +183,7 @@ describe('rendering a deprecated rule', () => { const page = renderRuleDoc(doc, '/repo/packages/eslint-plugin-fixture/docs/rules/new-rule.md', pkg, newRule); expect(page).not.toContain(':::caution'); expect(page).not.toContain('badge:'); + expect(page).not.toContain('
Status
'); }); test('llms.txt lists deprecated rules with their replacement, and nothing when there are none', () => { diff --git a/site/scripts/facts.test.ts b/site/scripts/facts.test.ts new file mode 100644 index 0000000..86b2b0a --- /dev/null +++ b/site/scripts/facts.test.ts @@ -0,0 +1,128 @@ +/** + * The header a rule page gets from sync.ts: the doc's summary as the lead + * paragraph, then the facts strip. Same cells in the same order on every page, + * a badge where a value is a state, plain text for a yes or no, and `is-no` on + * a negative. Proven on fixtures so every branch is covered, then on the real + * inventory so no rule falls outside them. + */ +import { describe, expect, test } from 'bun:test'; + +import { SITE_BASE, loadInventory, type PackageEntry, type RuleEntry } from './inventory'; +import { factsStrip, renderRuleDoc } from './sync'; + +const inventory = await loadInventory('src'); + +function rule(overrides: Partial = {}): RuleEntry { + return { + name: 'some-rule', + id: 'noctcore-fixture/some-rule', + description: 'Fixture rule.', + recommended: 'error', + fixable: false, + hasSuggestions: false, + typeInfo: 'none', + requiresOptions: false, + hasOptions: false, + deprecated: false, + replacedBy: [], + deprecatedSince: null, + deprecationMessage: null, + docsUrl: null, + ...overrides, + }; +} + +const plugin: PackageEntry = { + short: 'fixture', + npmName: '@noctcore/eslint-plugin-fixture', + kind: 'eslint-plugin', + namespace: 'noctcore-fixture', + version: '1.2.3', + description: 'Fixture plugin.', + dir: '/repo/packages/eslint-plugin-fixture', + rules: [], +}; + +/** The strip as `label: value` pairs, with ` (no)` after a quiet value. */ +function cells(html: string): string[] { + return [...html.matchAll(/
([^<]+)<\/dt>(.*?)<\/dd><\/div>/g)].map( + ([, label, quiet, value]) => `${label}: ${value}${quiet ? ' (no)' : ''}`, + ); +} + +describe('facts strip', () => { + test('a rule on in the preset, with nothing else set', () => { + expect(cells(factsStrip(plugin, rule()))).toEqual([ + `Package: fixturev1.2.3`, + 'Recommended preset: error', + 'Autofix: No (no)', + 'Suggestions: No (no)', + 'Options: None (no)', + 'Type information: Not needed (no)', + ]); + }); + + test('an opt-in rule with every capability', () => { + const html = factsStrip( + plugin, + rule({ recommended: 'off', fixable: true, hasSuggestions: true, requiresOptions: true, hasOptions: true, typeInfo: 'required' }), + ); + expect(cells(html).slice(1)).toEqual([ + 'Recommended preset: offopt-in', + 'Autofix: Yes', + 'Suggestions: Yes', + 'Options: Required', + 'Type information: required', + ]); + }); + + test('a rule left out of the preset, with optional options and optional types', () => { + const html = factsStrip(plugin, rule({ recommended: null, hasOptions: true, typeInfo: 'optional' })); + expect(cells(html)).toContain( + 'Recommended preset: not listedopt-in', + ); + expect(cells(html)).toContain('Options: Optional'); + expect(cells(html)).toContain('Type information: optional'); + }); + + test('a lint-meta rule gets the harness cells', () => { + const lintMeta: PackageEntry = { ...plugin, short: 'lint-meta-rules', kind: 'lint-meta', namespace: null }; + const html = factsStrip( + lintMeta, + rule({ factory: 'createThingRule', entry: '@noctcore/lint-meta-rules/i18n', category: 'source-text', ciCritical: false }), + ); + expect(cells(html).map((cell) => cell.split(':')[0])).toEqual([ + 'Package', + 'Runs under', + 'Factory', + 'Entry point', + 'Category', + 'Fails CI by default', + ]); + expect(cells(html)).toContain('Fails CI by default: No (no)'); + }); + + test('every real rule gets a complete strip', () => { + for (const pkg of inventory) { + for (const entry of pkg.rules) { + const html = factsStrip(pkg, entry); + expect(html.startsWith('
')).toBe(true); + expect(cells(html)).toHaveLength(entry.deprecated ? 7 : 6); + } + } + }); +}); + +describe('rule page header', () => { + test('the summary becomes the lead, above the facts strip, and the blockquote is gone', () => { + const sections = ['Why', 'What it flags', 'What it does not flag', 'When not to use it']; + const body = sections.map((section) => `## ${section}\n\nText.\n`).join('\n'); + const doc = `# \`noctcore-fixture/some-rule\`\n\n> Checks \`x\` twice.\n> **Opt-in.**\n\n${body}`; + const page = renderRuleDoc(doc, '/repo/packages/eslint-plugin-fixture/docs/rules/some-rule.md', plugin, rule()); + const rendered = page.slice(page.indexOf('---\n', 4) + 4); + expect(rendered.startsWith('\n
\n\nChecks `x` twice.\n**Opt-in.**\n\n
\n\n
')).toBe( + true, + ); + expect(rendered).not.toMatch(/^>/m); + }); +}); diff --git a/site/scripts/readmes.test.ts b/site/scripts/readmes.test.ts index b2b65df..2442c03 100644 --- a/site/scripts/readmes.test.ts +++ b/site/scripts/readmes.test.ts @@ -71,7 +71,7 @@ describe('generated README tables and rule-doc headers', () => { } }); - test('the site page drops the doc header and keeps its own metadata line', () => { + test('the site page drops the doc header and keeps its own facts strip', () => { const pkg = inventory.find((entry) => entry.kind === 'eslint-plugin')!; const rule = pkg.rules[0]!; const sections = ['Why', 'What it flags', 'What it does not flag', 'When not to use it']; @@ -81,7 +81,8 @@ describe('generated README tables and rule-doc headers', () => { const page = renderRuleDoc(doc, `${pkg.dir}/docs/rules/${rule.name}.md`, pkg, rule); expect(page).not.toContain(HEADER_BEGIN); expect(page).not.toContain(HEADER_END); - expect(page).toContain('**Recommended preset:**'); + expect(page).toContain('
'); + expect(page).toContain('
Recommended preset
'); }); }); diff --git a/site/scripts/sync.ts b/site/scripts/sync.ts index 4fc0cfa..adac6b6 100644 --- a/site/scripts/sync.ts +++ b/site/scripts/sync.ts @@ -145,24 +145,77 @@ function plainText(markdown: string): string { .trim(); } -function factsLine(pkg: PackageEntry, rule: RuleEntry): string { +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** One cell of the facts strip. `quiet` marks a negative value, so it reads quieter. */ +function fact(label: string, value: string, quiet = false): string { + return `
${label}
${value}
`; +} + +const badge = (kind: string, text: string) => `${text}`; +const yesNo = (label: string, yes: boolean) => fact(label, yes ? 'Yes' : 'No', !yes); + +function presetFact(rule: RuleEntry): string { + if (rule.recommended === 'error') return badge('on', 'error'); + const state = rule.recommended === 'off' ? badge('off', 'off') : badge('out', 'not listed'); + // A deprecated rule is in no preset on purpose; it is not one to turn on. + return rule.deprecated ? state : `${state}opt-in`; +} + +/** + * The facts strip under a rule's title and summary: the same cells in the same + * place on every rule page, with a badge where a value is a state and plain + * text for a yes or no. Markup and classes are the noctcore theme's + * (`.nc-facts` in src/styles/noctcore/components.css). + */ +export function factsStrip(pkg: PackageEntry, rule: RuleEntry): string { + const packageLink = + `${pkg.short}` + + `v${pkg.version}`; + let cells: string[]; if (pkg.kind === 'lint-meta') { // A reader who lands here from search has no other route to what runs a // lint-meta rule: not ESLint, the harness. The package page explains it. - return [ - `**Runs under:** [\`@noctcore/harness\` lint-meta](${SITE_BASE}/packages/${pkg.short}/), not ESLint`, - `**Factory:** \`${rule.factory}\` from \`${rule.entry}\``, - `**Category:** \`${rule.category}\``, - `**Fails CI by default:** ${rule.ciCritical ? 'yes' : 'no'}`, - ].join(' · '); + cells = [ + fact('Package', packageLink), + fact('Runs under', `@noctcore/harness lint-meta, not ESLint`), + fact('Factory', `${escapeHtml(rule.factory ?? '')}`), + fact('Entry point', `${escapeHtml(rule.entry ?? '')}`), + fact('Category', `${escapeHtml(rule.category ?? '')}`), + yesNo('Fails CI by default', rule.ciCritical === true), + ]; + } else { + const options = rule.requiresOptions + ? fact('Options', badge('options', 'Required')) + : fact('Options', rule.hasOptions ? 'Optional' : 'None', !rule.hasOptions); + const typeInfo = + rule.typeInfo === 'none' + ? fact('Type information', 'Not needed', true) + : fact('Type information', badge('types', rule.typeInfo)); + cells = [ + fact('Package', packageLink), + fact('Recommended preset', presetFact(rule)), + yesNo('Autofix', rule.fixable), + yesNo('Suggestions', rule.hasSuggestions), + options, + typeInfo, + ]; } - const typeInfo = { required: 'required', optional: 'used when available', none: 'not needed' }; - return [ - `**Recommended preset:** ${rule.recommended ? `\`${rule.recommended}\`` : 'not included'}`, - `**Autofix:** ${rule.fixable ? 'yes' : 'no'}`, - `**Suggestions:** ${rule.hasSuggestions ? 'yes' : 'no'}`, - `**Type information:** ${typeInfo[rule.typeInfo]}`, - ].join(' · '); + if (rule.deprecated) cells.push(fact('Status', badge('deprecated', 'deprecated'))); + return ['
', ...cells, '
'].join('\n'); +} + +/** + * The doc's `> summary` blockquote as the page's lead paragraph. The summary is + * Markdown (code spans, bold), so it stays Markdown inside the wrapper and the + * site's own renderer turns it into HTML; the blank lines around it are what + * make that happen inside an HTML block. + */ +function lead(quote: readonly string[]): string[] { + const text = quote.map((line) => line.replace(/^>\s?/, '')); + return ['
', '', ...text, '', '
']; } /** A replacement rule's page, relative to a rule page at rules///. */ @@ -180,7 +233,7 @@ function deprecationBanner(rule: RuleEntry): string[] { /** * Render one rule doc as a Starlight page. Throws on a doc out of shape. The * doc's generated status header is dropped: the page states the same facts in - * its own metadata line, and showing both would say everything twice. + * its own facts strip, and showing both would say everything twice. */ export function renderRuleDoc(source: string, docPath: string, pkg: PackageEntry, rule: RuleEntry): string { const lines = stripRuleHeader(source.replace(/\r\n/g, '\n')).split('\n'); @@ -202,7 +255,7 @@ export function renderRuleDoc(source: string, docPath: string, pkg: PackageEntry .join(' '), ); - const body: string[] = [...quote, ...deprecationBanner(rule), '', factsLine(pkg, rule)]; + const body: string[] = [...lead(quote), ...deprecationBanner(rule), '', factsStrip(pkg, rule)]; let inFence = false; for (const line of lines.slice(i)) { const fence = FENCE.exec(line); diff --git a/site/scripts/theme.test.ts b/site/scripts/theme.test.ts new file mode 100644 index 0000000..05b062c --- /dev/null +++ b/site/scripts/theme.test.ts @@ -0,0 +1,83 @@ +/** + * The noctcore theme's token contract, checked on the files this site ships: + * every `--nc-*` custom property that base.css and components.css read is set + * by the Nocturne preset, for dark (`:root`) and for light + * (`:root[data-theme='light']`). A token the preset forgets does not fail the + * build: the `var()` just resolves to nothing and a colour silently drops out, + * so this is the only thing that notices. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STYLES = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'styles', 'noctcore'); +const read = (file: string) => readFileSync(join(STYLES, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** + * Tokens with one value for both modes (the theme README's "Type, shape and + * labels"): the preset sets them once on `:root` and light inherits them. + */ +const MODE_INDEPENDENT = new Set([ + '--nc-font-display', + '--nc-font-body', + '--nc-font-mono', + '--nc-label-font', + '--nc-label-case', + '--nc-label-tracking', + '--nc-label-size', + '--nc-label-weight', + '--nc-radius', + '--nc-measure', +]); + +const readsOf = (css: string) => new Set([...css.matchAll(/var\(\s*(--nc-[\w-]+)/g)].map((m) => m[1]!)); +const setsOf = (css: string) => new Set([...css.matchAll(/(--nc-[\w-]+)\s*:/g)].map((m) => m[1]!)); + +/** The declarations inside the first block whose selector list starts with `selector`. */ +function block(css: string, selector: string): string { + const start = css.indexOf(`${selector},`); + if (start === -1) throw new Error(`no "${selector}" block in the preset`); + const open = css.indexOf('{', start); + return css.slice(open + 1, css.indexOf('}', open)); +} + +function missingTokens(base: string, components: string, preset: string): { dark: string[]; light: string[] } { + // Tokens base.css or components.css set themselves (the brand constants, a + // badge's own colours, a frame's unit) are not the preset's to provide. + const local = new Set([...setsOf(base), ...setsOf(components)]); + const needed = [...new Set([...readsOf(base), ...readsOf(components)])].filter((token) => !local.has(token)).sort(); + const dark = setsOf(block(preset, ':root')); + const light = setsOf(block(preset, ":root[data-theme='light']")); + return { + dark: needed.filter((token) => !dark.has(token)), + light: needed.filter((token) => !MODE_INDEPENDENT.has(token) && !light.has(token)), + }; +} + +describe('noctcore theme token contract', () => { + const base = read('base.css'); + const components = read('components.css'); + const preset = read('presets/nocturne.css'); + + test('the two shared files read tokens at all (the parse is not vacuous)', () => { + expect(readsOf(base)).toContain('--nc-ink'); + expect(readsOf(base)).toContain('--nc-success-edge'); + expect(readsOf(components)).toContain('--nc-card-bg'); + expect(readsOf(components)).toContain('--nc-motif'); + }); + + test('Nocturne sets every token base.css and components.css read, in dark and in light', () => { + expect(missingTokens(base, components, preset)).toEqual({ dark: [], light: [] }); + }); + + test('a token dropped from either mode is reported', () => { + const noLightEdge = preset.replace( + /(:root\[data-theme='light'\][\s\S]*?)--nc-success-edge:[^;]*;/, + '$1', + ); + expect(missingTokens(base, components, noLightEdge)).toEqual({ dark: [], light: ['--nc-success-edge'] }); + const noDarkRadius = preset.replace(/--nc-radius:[^;]*;/, ''); + expect(missingTokens(base, components, noDarkRadius)).toEqual({ dark: ['--nc-radius'], light: [] }); + }); +}); diff --git a/site/src/components/AllRules.astro b/site/src/components/AllRules.astro index 5b66f29..951ecbd 100644 --- a/site/src/components/AllRules.astro +++ b/site/src/components/AllRules.astro @@ -7,7 +7,8 @@ * descriptions all come from the plugins' exported metadata. */ import DeprecatedNote from './DeprecatedNote.astro'; -import { codeSpans, href, packages, plural, presetLabel } from '../lib/catalog'; +import RuleBadges from './RuleBadges.astro'; +import { codeSpans, href, packages, plural } from '../lib/catalog'; const rows = packages.flatMap((pkg) => pkg.rules.map((rule) => ({ pkg, rule }))); const total = rows.length; @@ -20,11 +21,30 @@ const optIn = pluginRules.filter(({ rule }) => rule.recommended === null).length const lintMetaCount = rows.length - pluginRules.length; const typed = rows.filter(({ rule }) => rule.typeInfo !== 'none'); const typeLabel = { required: 'required', optional: 'optional', none: '' } as const; -/** `react` for a plugin, `lint-meta-rules` for the catalog: the id a reader searches for. */ -const ruleId = (pkg: (typeof packages)[number], rule: (typeof rows)[number]['rule']) => - pkg.kind === 'eslint-plugin' ? rule.id : `${pkg.short}/${rule.name}`; +const stats = [ + ['Rules', total], + ['On in recommended', onInPreset], + ['Opt-in', registeredOff + optIn], + ['lint-meta', lintMetaCount], + ['Autofix', rows.filter(({ rule }) => rule.fixable).length], + ['Suggestions', rows.filter(({ rule }) => rule.hasSuggestions).length], +] as const; +/** `noctcore-react/` for a plugin, `lint-meta-rules/` for the catalog: with the name, the id a reader searches for. */ +const namespaceOf = (pkg: (typeof packages)[number]) => + pkg.kind === 'eslint-plugin' ? `${pkg.namespace}/` : `${pkg.short}/`; --- +
+ { + stats.map(([label, count]) => ( +
+
{label}
+
{count}
+
+ )) + } +
+

{total} rules across {plugins.length} ESLint plugins and {lintMeta.length} lint-meta catalog. Of the{' '} {pluginRules.length} ESLint rules, {onInPreset} are switched on by a recommended preset,{' '} @@ -52,38 +72,53 @@ const ruleId = (pkg: (typeof packages)[number], rule: (typeof rows)[number]['rul { - rows.map(({ pkg, rule }) => ( - - - - {ruleId(pkg, rule)} - - - - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - - {pkg.kind === 'eslint-plugin' ? ( - - {presetLabel(rule.recommended)} + packages.map((pkg) => ( + <> + + + {pkg.short} + + {plural(pkg.rules.length, 'rule')} · v{pkg.version} - ) : ( - harness - )} - - {[rule.fixable && '🔧', rule.hasSuggestions && '💡'].filter(Boolean).join(' ')} - {typeLabel[rule.typeInfo as keyof typeof typeLabel]} - + + + {pkg.rules.map((rule) => ( + + + + {namespaceOf(pkg)}{rule.name} + + + + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} + + + {pkg.kind === 'eslint-plugin' ? ( + + ) : ( + harness + )} + + + + + ))} + )) }

- Preset: severity in the package's configs.recommended; off means the - preset registers the rule switched off, not listed means it leaves the rule out; both are opt-in, so - you turn the rule on yourself. harness - means the rule is a lint-meta rule with no ESLint preset. ❌ deprecated: the rule still - works but is in no preset and will be removed; move to the rule named after it. Fix: 🔧 fixable with - --fix, 💡 offers editor suggestions. Types: whether the rule needs a - type-checked program (parserOptions.projectService), or uses one when present. + Preset: the rule's severity in its package's configs.recommended.{' '} + error is on. off (registered, switched off) + and not listed (left out) are both opt-in: + you turn the rule on yourself. needs options means it does nothing + until you configure it. harness marks a lint-meta rule, which has no ESLint + preset. deprecated marks a rule that still works but is in no preset and + will be removed; move to the rule named after it. Fix: + autofix with --fix, + suggestion in your editor. Types: whether the + rule needs a type-checked program (parserOptions.projectService), or uses one when present.

diff --git a/site/src/components/DeprecatedNote.astro b/site/src/components/DeprecatedNote.astro index 0959cfa..8e45c6f 100644 --- a/site/src/components/DeprecatedNote.astro +++ b/site/src/components/DeprecatedNote.astro @@ -16,7 +16,7 @@ const replacements = rule.deprecated ? replacementsOf(rule) : []; { rule.deprecated && ( - ❌ deprecated + deprecated {replacements.length > 0 && ( <> {' '}use{' '} diff --git a/site/src/components/Head.astro b/site/src/components/Head.astro deleted file mode 100644 index dde62a8..0000000 --- a/site/src/components/Head.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import Default from '@astrojs/starlight/components/Head.astro'; -// Space Grotesk for headings and the wordmark, JetBrains Mono for code. Both -// self-hosted, so the site makes no third-party font requests. -import '@fontsource-variable/space-grotesk'; -import '@fontsource-variable/jetbrains-mono'; ---- - - diff --git a/site/src/components/PackageGrid.astro b/site/src/components/PackageGrid.astro index 3a8c0eb..9d39a6b 100644 --- a/site/src/components/PackageGrid.astro +++ b/site/src/components/PackageGrid.astro @@ -1,14 +1,37 @@ --- /** - * Landing-page index of every package: name, npm description, rule and preset - * counts. All of it comes from the catalog, so a new rule shows up here without - * anyone editing the landing page. + * Landing-page index of every package: short name, version, npm name and + * description, and how its rules split across the recommended preset. All of it + * comes from the catalog, so a new rule shows up here without anyone editing the + * landing page. The split bar is decoration; the meta line says the same in words. */ import { enabledInPreset, href, packages, plural } from '../lib/catalog'; -/** A preset that turns nothing on says so, so a 0 does not read as a mistake. */ -const presetSummary = (enabled: number) => - enabled === 0 ? ' · 0 on in recommended, all opt-in' : ` · ${enabled} on in recommended`; +type Pkg = (typeof packages)[number]; + +/** How a plugin's rules split: on in recommended, registered off, left out. */ +function split(pkg: Pkg) { + const on = enabledInPreset(pkg); + const off = pkg.rules.filter((rule) => rule.recommended === 'off').length; + return { on, off, out: pkg.rules.length - on - off }; +} + +function meta(pkg: Pkg): string { + const rules = plural(pkg.rules.length, 'rule'); + if (pkg.kind !== 'eslint-plugin') return `${rules} · harness lint-meta catalog`; + const { on, off, out } = split(pkg); + return [rules, `${on} on`, off > 0 && `${off} off`, out > 0 && `${out} not listed`].filter(Boolean).join(' · '); +} + +function segments(pkg: Pkg): { kind: string; count: number }[] { + if (pkg.kind !== 'eslint-plugin') return [{ kind: 'harness', count: 1 }]; + const { on, off, out } = split(pkg); + return [ + { kind: 'on', count: on }, + { kind: 'off', count: off }, + { kind: 'out', count: out }, + ].filter(({ count }) => count > 0); +} ---
    @@ -16,15 +39,18 @@ const presetSummary = (enabled: number) => packages.map((pkg) => (
  • + + {pkg.short} + v{pkg.version} + {pkg.npmName} {pkg.description} - - {plural(pkg.rules.length, 'rule')} - {pkg.kind === 'eslint-plugin' - ? presetSummary(enabledInPreset(pkg)) - : ' · harness lint-meta catalog'} - {` · v${pkg.version}`} + + {meta(pkg)}
  • )) diff --git a/site/src/components/PageTitle.astro b/site/src/components/PageTitle.astro new file mode 100644 index 0000000..78039ec --- /dev/null +++ b/site/src/components/PageTitle.astro @@ -0,0 +1,12 @@ +--- +/** + * A rule id reads as its namespace, quiet, over the rule name + * (`noctcore-contracts/` then `fetch-must-check-ok`). Every other page keeps + * the plain title. components.css restores the h1 styles Starlight scopes to + * its own PageTitle. + */ +const title = Astro.locals.starlightRoute.entry.data.title; +const match = /^([\w-]+\/)(.+)$/.exec(title); +--- + +

    {match ? (<>{match[1]}{match[2]}) : title}

    diff --git a/site/src/components/RuleBadges.astro b/site/src/components/RuleBadges.astro new file mode 100644 index 0000000..c0d9390 --- /dev/null +++ b/site/src/components/RuleBadges.astro @@ -0,0 +1,45 @@ +--- +/** + * The badges a rule row shows in one table column: the preset state (with the + * opt-in and needs-options markers), the fix kinds, or the type information. + * Shared by the all-rules index and each package's table, so the two cannot + * mark the same rule differently. Text badges, never emoji: a screen reader + * and find-in-page both get words. + */ +import { presetLabel, type CatalogRule } from '../lib/catalog'; + +interface Props { + rule: CatalogRule; + column: 'preset' | 'fix' | 'types'; +} + +const { rule, column } = Astro.props; +const preset = presetLabel(rule.recommended); +// A deprecated rule is in no preset on purpose; it is not one to turn on. +const optIn = preset !== 'error' && !rule.deprecated; +--- + +{ + column === 'preset' && ( + <> + {preset} + {optIn && opt-in} + {rule.requiresOptions && ( + <> + {' '} + needs options + + )} + + ) +} +{ + column === 'fix' && ( + <> + {rule.fixable && autofix} + {rule.fixable && rule.hasSuggestions && ' '} + {rule.hasSuggestions && suggestion} + + ) +} +{column === 'types' && rule.typeInfo !== 'none' && {rule.typeInfo}} diff --git a/site/src/components/RuleTable.astro b/site/src/components/RuleTable.astro index eb6be54..561e22d 100644 --- a/site/src/components/RuleTable.astro +++ b/site/src/components/RuleTable.astro @@ -4,7 +4,8 @@ * rule's `meta` via the catalog. Nothing in it is typed by hand. */ import DeprecatedNote from './DeprecatedNote.astro'; -import { codeSpans, getPackage, href, presetLabel } from '../lib/catalog'; +import RuleBadges from './RuleBadges.astro'; +import { codeSpans, getPackage, href } from '../lib/catalog'; interface Props { pkg: string; @@ -12,7 +13,6 @@ interface Props { const pkg = getPackage(Astro.props.pkg); const isPlugin = pkg.kind === 'eslint-plugin'; -const typeLabel = { required: 'required', optional: 'optional', none: '' } as const; ---
    @@ -42,33 +42,33 @@ const typeLabel = { required: 'required', optional: 'optional', none: '' } as co pkg.rules.map((rule) => isPlugin ? ( - + {rule.name} - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - - - {presetLabel(rule.recommended)} - + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - {rule.fixable ? 'autofix' : rule.hasSuggestions ? 'suggestion' : ''} - {typeLabel[rule.typeInfo as keyof typeof typeLabel]} + + + ) : ( - + {rule.name} - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} + + {rule.category} - + {rule.entry} @@ -81,11 +81,13 @@ const typeLabel = { required: 'required', optional: 'optional', none: '' } as co { isPlugin && (

    - Preset: severity in configs.recommended; off means the preset - registers the rule switched off, not listed means it leaves the rule out; both are opt-in, so you - turn the rule on yourself. Fix: - whether the rule ships an autofix or an editor suggestion. Types: whether the rule needs a - type-checked program (parserOptions.projectService). + Preset: severity in configs.recommended.{' '} + off means the preset registers the rule switched off,{' '} + not listed means it leaves the rule out; both are{' '} + opt-in, so you turn the rule on yourself.{' '} + needs options means it does nothing until you configure it.{' '} + Fix: whether the rule ships an autofix or an editor suggestion. Types: whether + the rule needs a type-checked program (parserOptions.projectService), or uses one when present.

    ) } diff --git a/site/src/components/SiteTitle.astro b/site/src/components/SiteTitle.astro new file mode 100644 index 0000000..da75a51 --- /dev/null +++ b/site/src/components/SiteTitle.astro @@ -0,0 +1,37 @@ +--- +/** + * The site title as the noctcore wordmark over its crescent rule, then the + * product name. Replacing Starlight's SiteTitle drops its scoped styles, so the + * layout it had (logo height, gap, no wrapping) is restored below. + */ +import mark from '../assets/mark.svg'; + +const { siteTitleHref } = Astro.locals.starlightRoute; +--- + + + + noctcore + + ESLint plugins + + + diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 36e20eb..d4087b6 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -25,16 +25,25 @@ import AllQuickStarts from '../../components/AllQuickStarts.astro'; ## Is this for you? +
    +
    + **Yes, if** you run a TypeScript codebase on ESLint 9 or newer with flat config, and the bugs that reach review are structural: a component that drills props five levels down, a `fetch` with no timeout, a Prisma write outside its transaction, a log line that interpolates a user's email. These plugins catch those with high-precision, mostly syntactic rules, and every rule is `error` or `off`, never `warn` ([why](/eslint-plugins/getting-started/#severity-policy)). +
    +
    + **Probably not, if** you are on legacy `.eslintrc` config (these are flat-config only), you want a general style guide (use `typescript-eslint` and a formatter; these plugins assume both), or your codebase does not share the conventions a plugin encodes. Each package page says when it is a bad fit. +
    +
    + Every package is independently versioned. Install the one that matches the problem you have. ## Packages diff --git a/site/src/styles/noctcore/base.css b/site/src/styles/noctcore/base.css new file mode 100644 index 0000000..e723b46 --- /dev/null +++ b/site/src/styles/noctcore/base.css @@ -0,0 +1,140 @@ +/* noctcore docs base.css + The token contract. Every preset sets the same --nc-* tokens; this file + maps them onto Starlight 0.42's --sl-* variables once, so a preset never + names a Starlight variable for colour. Starlight's own CSS sits in cascade + layers and customCss is unlayered, so these rules win without !important. + The brand constants are decorative only (the crescent rule, the hero + button, the wordmark): never body text. Load first in customCss. */ + +:root, +::backdrop { + /* Brand constants */ + --nc-violet: #8b7cff; + --nc-cyan: #4dd0e1; + --nc-night: #0b1020; + --nc-gradient: linear-gradient(90deg, var(--nc-violet), var(--nc-cyan)); + + /* Type and measure */ + --sl-font: var(--nc-font-body); + --sl-font-mono: var(--nc-font-mono); + --sl-content-width: var(--nc-measure); + + /* Starlight's grey ramp is named for dark mode; the --nc names say what each step does */ + --sl-color-white: var(--nc-ink); + --sl-color-gray-1: var(--nc-ink-2); + --sl-color-gray-2: var(--nc-text); + --sl-color-gray-3: var(--nc-muted); + --sl-color-gray-4: var(--nc-faint); + --sl-color-gray-5: var(--nc-line); + --sl-color-gray-6: var(--nc-surface); + --sl-color-gray-7: var(--nc-raised); + --sl-color-black: var(--nc-bg); + + /* Accent */ + --sl-color-accent-low: var(--nc-accent-soft); + --sl-color-accent: var(--nc-accent); + --sl-color-accent-high: var(--nc-accent-strong); + + /* Aliases Starlight switches per mode. Mapped once here, so the presets hold all the mode logic */ + --sl-color-text: var(--nc-text); + --sl-color-text-accent: var(--nc-link); + --sl-color-text-invert: var(--nc-on-link); + --sl-color-bg: var(--nc-bg); + --sl-color-bg-nav: var(--nc-surface); + --sl-color-bg-sidebar: var(--nc-surface); + --sl-color-bg-inline-code: var(--nc-code-inline); + --sl-color-bg-accent: var(--nc-link); + --sl-color-hairline-light: var(--nc-line); + --sl-color-hairline: var(--nc-line-soft); + --sl-color-hairline-shade: var(--nc-bg); + + /* Signals: Starlight hues by role (blue note, purple tip, orange caution, red danger, green success) */ + --sl-color-blue-low: var(--nc-note-bg); + --sl-color-blue: var(--nc-note-edge); + --sl-color-blue-high: var(--nc-note-ink); + --sl-color-purple-low: var(--nc-tip-bg); + --sl-color-purple: var(--nc-tip-edge); + --sl-color-purple-high: var(--nc-tip-ink); + --sl-color-orange-low: var(--nc-caution-bg); + --sl-color-orange: var(--nc-caution-edge); + --sl-color-orange-high: var(--nc-caution-ink); + --sl-color-red-low: var(--nc-danger-bg); + --sl-color-red: var(--nc-danger-edge); + --sl-color-red-high: var(--nc-danger-ink); + --sl-color-green-low: var(--nc-success-bg); + --sl-color-green: var(--nc-success-edge); + --sl-color-green-high: var(--nc-success-ink); +} + +/* Shared structure: the wordmark, the crescent rule, the hero button, radius, focus. */ +.site-title { + font-family: var(--sl-font-mono); + font-weight: 600; + letter-spacing: -0.02em; + color: var(--sl-color-white); +} + +.nc-wordmark { + position: relative; +} + +.nc-wordmark::after { + content: ''; + position: absolute; + left: 0; + bottom: -0.3rem; + width: 1.75rem; + height: 2px; + border-radius: 2px; + background: var(--nc-gradient); +} + +h1#_top, +.hero h1, +.sl-markdown-content h2, +.sl-markdown-content h3 { + font-family: var(--nc-font-display); +} + +.hero h1::after { + content: ''; + display: block; + width: 6rem; + height: 0.25rem; + margin-top: 1rem; + border-radius: 999px; + background: var(--nc-gradient); +} + +@media (max-width: 49.99rem) { + .hero h1::after { + margin-inline: auto; + } +} + +.hero .sl-link-button.primary { + background: var(--nc-gradient); + border-color: transparent; + color: var(--nc-night); +} + +.starlight-aside, +.sl-link-card, +.card, +.pagination-links a { + border-radius: var(--nc-radius); +} + +.expressive-code { + --ec-brdRad: var(--nc-radius); +} + +:focus-visible { + outline: 2px solid var(--sl-color-accent); + outline-offset: 2px; +} + +.expressive-code pre, +code { + font-variant-ligatures: none; +} diff --git a/site/src/styles/noctcore/components.css b/site/src/styles/noctcore/components.css new file mode 100644 index 0000000..63e2558 --- /dev/null +++ b/site/src/styles/noctcore/components.css @@ -0,0 +1,793 @@ +/* noctcore docs components.css + The site pieces the noctcore docs sites share. Every colour comes from + Starlight's --sl-* variables or the preset's --nc-* tokens, so each piece + follows the preset in dark and light. No bare element selectors: it + only touches the classes below and Starlight's own component classes. + + Load order in customCss: base.css, components.css, presets/.css, + then the site's own CSS. The preset comes after this file so it can tune a + component. + Markup for each piece is in COMPONENTS.md. The cells of the grid pieces + (facts, stats, the fit pair, the split bar) reset their margin: Starlight's + Markdown content puts a top margin between any two sibling elements. */ + +/* --------------------------------------------------------------------------- + Labels. The small line over facts, stats and stacked table cells. The + preset sets the look through --nc-label-*. +--------------------------------------------------------------------------- */ +.nc-label, +.nc-facts dt, +.nc-stats dt, +.package-card-version { + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + line-height: 1.3; + color: var(--sl-color-gray-3); +} + +/* A page's opening paragraph: the showcase-kit lead, and the rule summary that + sync.ts writes where the doc has its "> summary" blockquote. */ +.sl-markdown-content .nc-lead, +.nc-lead { + font-size: var(--sl-text-lg); + line-height: 1.6; + color: var(--sl-color-gray-2); + text-wrap: pretty; +} + +/* Starlight styles inline code only inside the Markdown body; a lead can sit above it. */ +.nc-lead code { + padding: 0.125rem 0.375rem; + background-color: var(--sl-color-bg-inline-code); + font-family: var(--__sl-font-mono); + font-size: 0.875em; +} + +/* --------------------------------------------------------------------------- + Site title: the wordmark (its crescent rule is in base.css), then the + product. Markup in COMPONENTS.md (a SiteTitle override). +--------------------------------------------------------------------------- */ +.site-title .nc-sep { + color: var(--sl-color-gray-4); + font-weight: 400; +} + +.site-title .nc-product { + overflow: hidden; + color: var(--sl-color-gray-2); + font-weight: 500; + text-overflow: ellipsis; +} + +/* A PageTitle override drops Starlight's scoped h1 styles; these are the same + values, at zero specificity so a preset can still restyle the title. */ +:where(.sl-container > h1#_top) { + margin-top: 1rem; + font-size: var(--sl-text-h1); + line-height: var(--sl-line-height-headings); + font-weight: 600; + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Version pill (showcase-kit header, links to the changelog). +--------------------------------------------------------------------------- */ +.nc-version { + display: inline-flex; + align-items: center; + height: 1.6rem; + padding-inline: 0.55rem; + border-radius: calc(var(--nc-radius) * 2 + 0.25rem); + font: 600 0.75rem/1 var(--__sl-font-mono); + color: var(--sl-color-text-accent); + background: var(--sl-color-accent-low); + text-decoration: none; + white-space: nowrap; +} + +.nc-version:hover { + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Window-frame figure: a raw capture in the frame showcase-kit draws, on the + preset's --nc-motif backdrop. Sizes are relative to the frame's width, so it + scales like the rendered image. Set --nc-win on .nc-window when a window is + narrower than its frame (the hero stack uses 74). +--------------------------------------------------------------------------- */ +.nc-shot { + margin: 0; +} + +.nc-shot figcaption { + margin-top: 0.75rem; + font-size: var(--sl-text-sm); + line-height: 1.55; + color: var(--sl-color-gray-3); +} + +.nc-frame { + container-type: inline-size; + padding: 4.5%; + border-radius: calc(var(--nc-radius) * 1.25); + background: var(--nc-motif); +} + +.nc-window { + --nc-u: calc(var(--nc-win, 100) * 0.01cqi); + position: relative; + overflow: hidden; + border-radius: calc(var(--nc-u) * 0.97); + background: #0f1017; + box-shadow: + 0 calc(var(--nc-u) * 2.4) calc(var(--nc-u) * 5) calc(var(--nc-u) * -1) rgba(2, 4, 12, 0.65), + 0 0 0 1px rgba(255, 255, 255, 0.07); +} + +.nc-window > img { + display: block; + width: 100%; + height: auto; +} + +/* The title bar showcase-kit draws with frame.theme 'dark'. */ +.nc-titlebar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--nc-u) * 2.78); + background: #1b1c26; + border-bottom: 1px solid #0a0a10; +} + +.nc-lights { + position: absolute; + top: 50%; + left: var(--nc-u); + display: flex; + gap: calc(var(--nc-u) * 0.55); + transform: translateY(-50%); +} + +.nc-lights i { + width: calc(var(--nc-u) * 0.85); + height: calc(var(--nc-u) * 0.85); + border-radius: 50%; + background: #ff5f57; +} + +.nc-lights i:nth-child(2) { + background: #febc2e; +} + +.nc-lights i:nth-child(3) { + background: #28c840; +} + +.nc-wtitle { + font: 500 max(calc(var(--nc-u) * 0.95), 5px)/1 var(--sl-font-system, system-ui, sans-serif); + color: #a3a6b8; +} + +/* The hero stack: what `showcase hero` composes, minus its text. */ +.nc-banner { + position: relative; + container-type: inline-size; + width: 100%; + max-width: 34rem; + aspect-ratio: 5 / 4; + margin-inline: auto; + overflow: hidden; + border-radius: calc(var(--nc-radius) * 1.5); + background: var(--nc-motif); +} + +.nc-banner .nc-window { + --nc-win: 74; + position: absolute; + width: 74%; +} + +.nc-banner .nc-window:first-child { + top: 8%; + left: 4%; + transform: rotate(-5deg); +} + +.nc-banner .nc-window:last-child { + right: 3%; + bottom: 9%; + transform: rotate(3.5deg); +} + +/* --------------------------------------------------------------------------- + Cards. Starlight's LinkCard and Card, and the ESLint package cards, share one + surface: --nc-card-bg and --nc-card-border from the preset. +--------------------------------------------------------------------------- */ +.sl-link-card, +.card { + background-color: var(--nc-card-bg); + border-color: var(--nc-card-border); +} + +.package-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15.5rem, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.package-grid .package-card { + margin: 0; +} + +.package-card a { + position: relative; + display: flex; + flex-direction: column; + gap: 0.45rem; + height: 100%; + padding: 1rem 1.1rem 0.95rem; + overflow: hidden; + border: 1px solid var(--nc-card-border); + border-radius: var(--nc-radius); + background: var(--nc-card-bg); + color: var(--sl-color-gray-2); + text-decoration: none; +} + +.package-card a::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: var(--nc-gradient); + opacity: 0; +} + +.package-card a:hover, +.package-card a:focus-visible { + border-color: var(--sl-color-accent); +} + +.package-card a:hover::before, +.package-card a:focus-visible::before { + opacity: 1; +} + +.package-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.package-card-short { + font-family: var(--nc-font-display); + font-size: var(--sl-text-lg); + font-weight: 600; + line-height: 1.2; + color: var(--sl-color-white); +} + +.package-card-name { + font: 500 var(--sl-text-2xs)/1.4 var(--__sl-font-mono); + color: var(--sl-color-gray-3); + overflow-wrap: anywhere; +} + +.package-card-desc { + font-size: var(--sl-text-sm); + line-height: 1.5; +} + +.package-card-meta { + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + color: var(--sl-color-gray-3); +} + +/* How a package's rules split across its preset: on, registered off, not listed. */ +.nc-split { + display: flex; + gap: 2px; + height: 0.3125rem; + margin-top: auto; + overflow: hidden; + border-radius: 999px; +} + +.nc-split i { + min-width: 3px; + margin: 0; + background: var(--sl-color-gray-5); +} + +.nc-split .on { + background: var(--sl-color-green); +} + +.nc-split .off { + background: var(--sl-color-gray-4); +} + +.nc-split .harness { + background: var(--sl-color-accent); +} + +/* --------------------------------------------------------------------------- + Badges. Rule state, as scannable chips: the preset severity, autofix, + suggestions, required options, type information, deprecated. The .pill + classes the ESLint site's tables already emit get the same look. +--------------------------------------------------------------------------- */ +:is(.nc-badge, .pill) { + --nc-badge-bg: transparent; + --nc-badge-ink: var(--sl-color-gray-2); + --nc-badge-edge: var(--sl-color-gray-4); + display: inline-flex; + align-items: center; + gap: 0.3em; + padding: 0.0625rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--nc-badge-edge) 55%, transparent); + border-radius: calc(var(--nc-radius) * 0.5 + 0.1875rem); + background: var(--nc-badge-bg); + color: var(--nc-badge-ink); + font: 600 var(--sl-text-2xs)/1.5 var(--__sl-font-mono); + white-space: nowrap; +} + +:is(.nc-badge--on, .pill-error) { + --nc-badge-bg: var(--sl-color-green-low); + --nc-badge-ink: var(--sl-color-green-high); + --nc-badge-edge: var(--sl-color-green); +} + +:is(.nc-badge--off, .pill-off) { + --nc-badge-ink: var(--sl-color-gray-2); + --nc-badge-edge: var(--sl-color-gray-4); +} + +:is(.nc-badge--out, .pill-not-listed) { + --nc-badge-ink: var(--sl-color-gray-3); + border-style: dashed; +} + +:is(.nc-badge--fix) { + --nc-badge-bg: var(--sl-color-purple-low); + --nc-badge-ink: var(--sl-color-purple-high); + --nc-badge-edge: var(--sl-color-purple); +} + +:is(.nc-badge--suggest) { + --nc-badge-bg: var(--sl-color-blue-low); + --nc-badge-ink: var(--sl-color-blue-high); + --nc-badge-edge: var(--sl-color-blue); +} + +:is(.nc-badge--options) { + --nc-badge-bg: var(--sl-color-orange-low); + --nc-badge-ink: var(--sl-color-orange-high); + --nc-badge-edge: var(--sl-color-orange); +} + +:is(.nc-badge--types) { + --nc-badge-bg: var(--sl-color-blue-low); + --nc-badge-ink: var(--sl-color-blue-high); + --nc-badge-edge: var(--sl-color-blue); +} + +:is(.nc-badge--harness, .pill-harness) { + --nc-badge-bg: var(--sl-color-accent-low); + --nc-badge-ink: var(--sl-color-text-accent); + --nc-badge-edge: var(--sl-color-accent); +} + +:is(.nc-badge--deprecated, .pill-deprecated) { + --nc-badge-bg: var(--sl-color-red-low); + --nc-badge-ink: var(--sl-color-red-high); + --nc-badge-edge: var(--sl-color-red); +} + +/* "opt-in" beside an off or not-listed preset: the rule is yours to turn on. */ +.nc-optin { + margin-inline-start: 0.35rem; + font-size: var(--sl-text-2xs); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +.deprecated-note { + display: block; + margin-top: 0.25rem; + font-size: var(--sl-text-xs); +} + +/* --------------------------------------------------------------------------- + Rule facts: the strip under a rule's title. sync.ts writes it in place of + the "Recommended preset: ... · Autofix: ..." line. Same six cells on every + rule page, so the eye finds a fact in the same place each time. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-facts, +.nc-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.25rem, 1fr)); + margin-inline: 0; + border-block: 1px solid var(--sl-color-hairline-light); +} + +.nc-facts > div { + display: grid; + align-content: start; + gap: 0.3rem; + margin: 0; + padding: 0.7rem 0.9rem 0.8rem 0; +} + +.nc-facts dt { + margin: 0; +} + +.nc-facts dd { + margin: 0; + padding: 0; + font-size: var(--sl-text-sm); + line-height: 1.4; + color: var(--sl-color-white); +} + +.nc-facts dd.is-no { + color: var(--sl-color-gray-3); +} + +.nc-facts dd code { + font-size: var(--sl-text-xs); +} + +.nc-facts-note { + margin-inline-start: 0.35rem; + white-space: nowrap; + font: 500 var(--sl-text-2xs) var(--__sl-font-mono); + color: var(--sl-color-gray-3); +} + +/* A rule title reads as namespace plus name; the PageTitle override splits it. */ +.nc-title-ns { + display: block; + margin-bottom: 0.2em; + font: 500 max(0.875rem, 0.36em)/1.3 var(--__sl-font-mono); + letter-spacing: 0; + color: var(--sl-color-gray-3); +} + +/* --------------------------------------------------------------------------- + Incorrect and Correct examples. ec.config.mjs puts .verdict-bad or + .verdict-good on the frame. Each frame restyles itself by setting Expressive + Code's own variables: a tinted title bar, a coloured frame edge, a verdict + mark before the title. Three signals, never colour alone. +--------------------------------------------------------------------------- */ +.expressive-code .verdict-bad { + --nc-verdict: var(--sl-color-red); + --nc-verdict-bg: var(--sl-color-red-low); + --nc-verdict-ink: var(--sl-color-red-high); + --nc-verdict-mark: '\2715'; +} + +.expressive-code .verdict-good { + --nc-verdict: var(--sl-color-green); + --nc-verdict-bg: var(--sl-color-green-low); + --nc-verdict-ink: var(--sl-color-green-high); + --nc-verdict-mark: '\2713'; +} + +.expressive-code :is(.verdict-bad, .verdict-good) { + --ec-brdCol: color-mix(in srgb, var(--nc-verdict) 55%, transparent); + --ec-frm-edTabBarBg: var(--nc-verdict-bg); + --ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--nc-verdict) 55%, transparent); + --ec-frm-edActTabBg: transparent; + --ec-frm-edActTabFg: var(--nc-verdict-ink); + --ec-frm-edActTabIndTopCol: transparent; +} + +.expressive-code :is(.verdict-bad, .verdict-good) .header .title { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 600; +} + +.expressive-code :is(.verdict-bad, .verdict-good) .header .title::before { + content: var(--nc-verdict-mark); + display: inline-grid; + place-items: center; + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + background: var(--nc-verdict); + color: var(--sl-color-black); + font-size: 0.6875rem; + font-weight: 800; +} + +.expressive-code :is(.verdict-bad, .verdict-good) pre { + box-shadow: inset 3px 0 0 var(--nc-verdict); +} + +.expressive-code :is(.verdict-bad, .verdict-good) + * { + margin-top: 0.75rem; +} + +/* --------------------------------------------------------------------------- + Tables. The ESLint rule tables and the showcase-kit config reference. Wide + screens get a dense table; below 40rem each row becomes a stacked entry + (name, text, then the badges), labelled from data-label on each cell. +--------------------------------------------------------------------------- */ +.rule-table-wrap { + overflow-x: auto; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) { + display: table; + width: 100%; + font-size: var(--sl-text-sm); + border-collapse: collapse; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) :is(th, td) { + vertical-align: top; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) thead th { + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +/* The all-rules index has no table of contents, so it takes a wider column. */ +main:has(.rule-table-all) { + --sl-content-width: 68rem; +} + +.sl-markdown-content .rule-table td:first-child { + width: 30%; +} + +.sl-markdown-content .rule-table td:first-child code { + overflow-wrap: break-word; + background: none; + padding: 0; + font-weight: 600; +} + +/* A rule id as two lines: the namespace, quiet, over the rule's name. Both stay + in one code element, so find-in-page still matches the full id. */ +.nc-id-ns { + display: block; + font-size: 0.85em; + font-weight: 400; + color: var(--sl-color-gray-3); +} + +/* A package's rows start with a group row: the package name and its count. */ +.sl-markdown-content .rule-table .nc-group th { + padding-top: 1.4rem; + border-bottom: 1px solid var(--sl-color-gray-4); + font-family: var(--nc-font-display); + font-size: var(--sl-text-base); + font-weight: 600; + color: var(--sl-color-white); + text-align: start; +} + +.nc-group-count { + margin-inline-start: 0.5rem; + font: 500 var(--sl-text-2xs) var(--__sl-font-mono); + color: var(--sl-color-gray-3); +} + +.sl-markdown-content .nc-ref td:first-child, +.sl-markdown-content .nc-ref td:last-child { + white-space: nowrap; +} + +.sl-markdown-content .nc-ref .sl-badge + .sl-badge { + margin-inline-start: 0.25rem; +} + +@media (max-width: 40rem) { + .rule-table-wrap { + overflow: visible; + } + + .sl-markdown-content :is(.rule-table, .nc-ref), + .sl-markdown-content :is(.rule-table, .nc-ref) :is(tbody, tr) { + display: block; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + + .sl-markdown-content :is(.rule-table, .nc-ref) tbody tr { + padding-block: 0.8rem; + border-bottom: 1px solid var(--sl-color-gray-5); + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td { + display: block; + width: auto !important; + padding: 0; + border: 0; + white-space: normal; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td + td { + margin-top: 0.3rem; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td:nth-child(n + 3) { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0.5rem 0.9rem 0 0; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td:nth-child(n + 3):empty { + display: none; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td[data-label]:nth-child(n + 3)::before { + content: attr(data-label); + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + color: var(--sl-color-gray-3); + } + + .sl-markdown-content .rule-table .nc-group { + display: block; + border: 0; + } + + .sl-markdown-content .rule-table .nc-group th { + display: block; + padding-inline: 0; + } +} + +.rule-table-legend, +.quickstart-note, +.site-footer-note { + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); +} + +/* Summary numbers over the all-rules index. */ +.sl-markdown-content .nc-stats, +.nc-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 1px; + margin-inline: 0; + overflow: hidden; + border: 1px solid var(--nc-card-border); + border-radius: var(--nc-radius); + background: var(--nc-card-border); +} + +.nc-stats > div { + display: grid; + gap: 0.15rem; + margin: 0; + padding: 0.75rem 0.9rem; + background: var(--nc-card-bg); +} + +.nc-stats dd { + order: -1; + margin: 0; + font-family: var(--nc-font-display); + font-size: var(--sl-text-2xl); + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Tabs with many entries (the twelve quick-start tabs) wrap into a row of + chips instead of scrolling sideways past the fold. +--------------------------------------------------------------------------- */ +starlight-tabs:has(.tab:nth-child(6)) [role='tablist'] { + flex-wrap: wrap; + gap: 0.35rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--sl-color-hairline-light); +} + +starlight-tabs:has(.tab:nth-child(6)) .tab { + margin-bottom: 0; +} + +starlight-tabs:has(.tab:nth-child(6)) .tab > [role='tab'] { + padding: 0.25rem 0.7rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: calc(var(--nc-radius) * 2 + 0.25rem); + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); +} + +starlight-tabs:has(.tab:nth-child(6)) .tab > [role='tab'][aria-selected='true'] { + border-color: var(--sl-color-text-accent); + background: var(--sl-color-accent-low); + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Who a site is for: the two landing-page lists side by side. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-fit, +.nc-fit { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 0.75rem; +} + +.nc-fit > div { + margin: 0; + padding: 1rem 1.1rem; + border: 1px solid var(--nc-card-border); + border-top: 2px solid var(--sl-color-green); + border-radius: var(--nc-radius); + background: var(--nc-card-bg); + font-size: var(--sl-text-sm); +} + +.nc-fit > div + div { + border-top-color: var(--sl-color-orange); +} + +.nc-fit > div > p + p { + margin-top: 0.5rem; +} + +/* --------------------------------------------------------------------------- + Changelog: a release's version and date as one line. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-release-meta, +.nc-release-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.75rem; + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); +} + +.nc-release-meta b { + font-size: var(--sl-text-lg); + font-weight: 600; + color: var(--sl-color-white); +} + +.nc-sha { + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); + white-space: nowrap; +} diff --git a/site/src/styles/noctcore/expressive-code.mjs b/site/src/styles/noctcore/expressive-code.mjs new file mode 100644 index 0000000..1e9b7fd --- /dev/null +++ b/site/src/styles/noctcore/expressive-code.mjs @@ -0,0 +1,102 @@ +/* noctcore docs: Expressive Code settings for the Nocturne preset. + + // ec.config.mjs + import { defineEcConfig } from '@astrojs/starlight/expressive-code'; + import { noctcoreCodeConfig } from './src/styles/noctcore/expressive-code.mjs'; + + export default defineEcConfig({ + ...noctcoreCodeConfig('nocturne'), + plugins: [], + }); + + What noctcoreCodeConfig sets, and why: + - themes: a dark and a light theme built from the preset's --nc-code-ink and + --nc-syn-* values. Starlight switches between them with its own theme + picker, because one is type 'dark' and the other 'light'. + - useStarlightUiThemeColors: true, so title bars, tabs and borders follow the + preset through Starlight's --sl-* variables. Starlight turns this off by + default once a site passes its own themes. + - customizeTheme: puts the code area on the preset's --nc-code-bg. Starlight + would otherwise use --sl-color-gray-6 in dark mode and -7 in light. + - minSyntaxHighlightingColorContrast: 0. By default Expressive Code nudges + token colours toward 5.5:1 against a fixed grey it uses for that sum, not + against the real code background. These colours are checked against the + real background instead: 4.5:1 or more, see contrast.md. + Keep these values in step with presets/nocturne.css when either changes. */ +import { ExpressiveCodeTheme } from '@astrojs/starlight/expressive-code'; + +const SYNTAX = { + "nocturne": { + "dark": { + "bg": "#0e1528", + "ink": "#d6ddef", + "keyword": "#b3a8ff", + "string": "#7fe0ea", + "number": "#ffc98a", + "function": "#9fe8f1", + "property": "#dde3f1", + "comment": "#8792b3", + "punct": "#939fbf" + }, + "light": { + "bg": "#f5f7fc", + "ink": "#232c44", + "keyword": "#5a3fd0", + "string": "#0b6f7e", + "number": "#9a4f00", + "function": "#0b5563", + "property": "#232c44", + "comment": "#5f6b88", + "punct": "#56627f" + } + } +}; + +/* TextMate scopes per token role, for the TypeScript, JavaScript, shell and + JSON grammars the docs use. */ +const SCOPES = [ + ['comment', ['comment', 'punctuation.definition.comment'], 'italic'], + ['keyword', ['keyword', 'storage', 'storage.type', 'storage.modifier', 'keyword.control', 'constant.language', 'variable.language']], + ['string', ['string', 'string.template', 'punctuation.definition.string', 'punctuation.definition.template-expression']], + ['number', ['constant.numeric', 'constant.other']], + ['function', ['entity.name.function', 'support.function', 'meta.function-call entity.name.function', 'entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.name.command']], + ['property', ['variable.other.property', 'meta.object-literal.key', 'support.type.property-name', 'entity.other.attribute-name']], + ['punct', ['punctuation', 'meta.brace', 'keyword.operator']], +]; + +function theme(preset, type) { + const c = SYNTAX[preset][type]; + return new ExpressiveCodeTheme({ + name: `noctcore-${preset}-${type}`, + type, + colors: { 'editor.background': c.bg, 'editor.foreground': c.ink }, + tokenColors: SCOPES.map(([key, scope, fontStyle]) => ({ + scope, + settings: fontStyle ? { foreground: c[key], fontStyle } : { foreground: c[key] }, + })), + }); +} + +/** The dark and light themes for one preset. */ +export function noctcoreCodeThemes(preset = 'nocturne') { + if (!SYNTAX[preset]) throw new Error(`Unknown noctcore preset "${preset}". Use one of: ${Object.keys(SYNTAX).join(', ')}.`); + return [theme(preset, 'dark'), theme(preset, 'light')]; +} + +/** Everything ec.config.mjs needs for one preset; spread it and add the site's plugins. */ +export function noctcoreCodeConfig(preset = 'nocturne') { + return { + themes: noctcoreCodeThemes(preset), + useStarlightUiThemeColors: true, + minSyntaxHighlightingColorContrast: 0, + customizeTheme(theme) { + theme.styleOverrides.frames = { + ...theme.styleOverrides.frames, + editorBackground: 'var(--nc-code-bg)', + terminalBackground: 'var(--nc-code-bg)', + editorActiveTabBackground: 'var(--nc-code-bg)', + }; + return theme; + }, + }; +} diff --git a/site/src/styles/noctcore/presets/nocturne.css b/site/src/styles/noctcore/presets/nocturne.css new file mode 100644 index 0000000..3133ac3 --- /dev/null +++ b/site/src/styles/noctcore/presets/nocturne.css @@ -0,0 +1,169 @@ +/* noctcore docs preset: Nocturne + The current night-sky theme, refined: brand-tuned signal colours and the crescent rule under every page title. Its sans body keeps the 113-rule index and the code-heavy rule pages easy to scan. + Type: Space Grotesk for headings, system UI for text, JetBrains Mono for code. + Needs: @fontsource-variable/space-grotesk, @fontsource-variable/jetbrains-mono. + Dark is the default, as in Starlight: :root holds the dark values and + :root[data-theme='light'] overrides them. Load after base.css and + components.css. Contrast: every text pair passes WCAG AA, see contrast.md. */ +@import '@fontsource-variable/space-grotesk'; +@import '@fontsource-variable/jetbrains-mono'; + +:root, +::backdrop { + /* Type, shape and labels */ + --nc-font-display: 'Space Grotesk Variable', 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; + --nc-font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --nc-font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + --nc-label-font: var(--nc-font-body); + --nc-label-case: none; + --nc-label-tracking: 0; + --nc-label-size: 0.8125rem; + --nc-label-weight: 600; + --nc-radius: 0.5rem; + --nc-measure: 50rem; + + /* Surfaces */ + --nc-bg: #0b1020; + --nc-surface: #10172b; + --nc-raised: #141c33; + --nc-line: #26324f; + --nc-line-soft: #1a2340; + + /* Ink */ + --nc-ink: #eef2fb; + --nc-ink-2: #dde3f1; + --nc-text: #b7c0d8; + --nc-muted: #939fbf; + --nc-faint: #5b6a90; + + /* Accent */ + --nc-link: #5fd6e5; + --nc-on-link: #0b1020; + --nc-accent: #8b7cff; + --nc-accent-soft: #1b2450; + --nc-accent-strong: #5fd6e5; + + /* Signals: Starlight asides and badges, the ESLint verdict frames and rule badges */ + --nc-note-bg: #0f2536; + --nc-note-edge: #4dd0e1; + --nc-note-ink: #9fe8f1; + --nc-tip-bg: #1d1a45; + --nc-tip-edge: #8b7cff; + --nc-tip-ink: #cbc4ff; + --nc-caution-bg: #2e230f; + --nc-caution-edge: #f0b545; + --nc-caution-ink: #ffd89a; + --nc-danger-bg: #35141c; + --nc-danger-edge: #ff7b72; + --nc-danger-ink: #ffbdb8; + --nc-success-bg: #0f2b22; + --nc-success-edge: #56d68f; + --nc-success-ink: #a9efc6; + + /* Components: cards and package cards */ + --nc-card-bg: #10172b; + --nc-card-border: #26324f; + + /* Code: frames read these; the Expressive Code theme carries the syn-* values */ + --nc-code-inline: #1c2644; + --nc-code-bg: #0e1528; + --nc-code-ink: #d6ddef; + --nc-syn-keyword: #b3a8ff; + --nc-syn-string: #7fe0ea; + --nc-syn-number: #ffc98a; + --nc-syn-function: #9fe8f1; + --nc-syn-property: #dde3f1; + --nc-syn-comment: #8792b3; + --nc-syn-punct: #939fbf; + + /* Motif: the window-frame backdrop */ + --nc-motif: linear-gradient(135deg, #0f766e, #1e1b4b); +} + +:root[data-theme='light'], +[data-theme='light'] ::backdrop { + /* Surfaces */ + --nc-bg: #ffffff; + --nc-surface: #f5f7fc; + --nc-raised: #f5f7fc; + --nc-line: #c9d1e3; + --nc-line-soft: #e9edf7; + + /* Ink */ + --nc-ink: #0b1020; + --nc-ink-2: #232c44; + --nc-text: #3a4560; + --nc-muted: #56627f; + --nc-faint: #7b86a4; + + /* Accent */ + --nc-link: #5a4ad6; + --nc-on-link: #ffffff; + --nc-accent: #5a4ad6; + --nc-accent-soft: #ebe9ff; + --nc-accent-strong: #2a2470; + + /* Signals: Starlight asides and badges, the ESLint verdict frames and rule badges */ + --nc-note-bg: #e3f6f9; + --nc-note-edge: #0e8a9a; + --nc-note-ink: #0b5563; + --nc-tip-bg: #eeebff; + --nc-tip-edge: #6f5ef0; + --nc-tip-ink: #3b2fa0; + --nc-caution-bg: #fcf1dc; + --nc-caution-edge: #c98512; + --nc-caution-ink: #7a4a06; + --nc-danger-bg: #fde8e6; + --nc-danger-edge: #d8453b; + --nc-danger-ink: #8f1f18; + --nc-success-bg: #e2f5ea; + --nc-success-edge: #1f9d5a; + --nc-success-ink: #135c35; + + /* Components: cards and package cards */ + --nc-card-bg: #f5f7fc; + --nc-card-border: #c9d1e3; + + /* Code: frames read these; the Expressive Code theme carries the syn-* values */ + --nc-code-inline: #eceff8; + --nc-code-bg: #f5f7fc; + --nc-code-ink: #232c44; + --nc-syn-keyword: #5a3fd0; + --nc-syn-string: #0b6f7e; + --nc-syn-number: #9a4f00; + --nc-syn-function: #0b5563; + --nc-syn-property: #232c44; + --nc-syn-comment: #5f6b88; + --nc-syn-punct: #56627f; + + /* Motif: the window-frame backdrop */ + --nc-motif: linear-gradient(135deg, #0f766e, #1e1b4b); +} + +/* Structure */ +:root:not([data-theme='light']) body { + background-image: + radial-gradient(70rem 30rem at 20% -8rem, rgba(139, 124, 255, 0.16), transparent 65%), + radial-gradient(50rem 24rem at 90% -10rem, rgba(77, 208, 225, 0.1), transparent 65%); + background-repeat: no-repeat; +} + +h1#_top:not(.hero h1)::after { + content: ''; + display: block; + width: 3rem; + height: 0.1875rem; + margin-top: 0.875rem; + border-radius: 999px; + background: var(--nc-gradient); +} + +.page > .header { + border-bottom: 1px solid var(--sl-color-hairline-light); +} + +.sl-link-card:hover, +.sl-link-card:focus-within { + border-color: var(--sl-color-accent); + background: var(--sl-color-gray-6); +} diff --git a/site/src/styles/site.css b/site/src/styles/site.css new file mode 100644 index 0000000..17eb36c --- /dev/null +++ b/site/src/styles/site.css @@ -0,0 +1,39 @@ +/* The ESLint plugins site's own rules, loaded last in customCss after the + noctcore theme (noctcore/base.css, components.css, presets/nocturne.css). + Only what the theme does not cover lives here. */ + +/* On a phone the header has room for the mark, the search and menu buttons + and a short title: the product name alone, without the wordmark. */ +@media (max-width: 29.99rem) { + .site-title { + gap: 0.5rem; + font-size: 0.9375rem; + } + + .site-title :is(.nc-wordmark, .nc-sep) { + display: none; + } +} + +.hero .tagline { + max-width: 38rem; +} + +.hero .sl-link-button.primary:hover, +.hero .sl-link-button.primary:focus-visible { + color: var(--nc-night); + filter: brightness(1.08); +} + +/* The options tables in the rule docs are plain Markdown tables, which + Starlight scrolls sideways at phone width. Their last column ("Meaning") + gets a floor so it does not wrap one word per line. */ +@media (max-width: 40rem) { + .sl-markdown-content table:not(.rule-table) td:last-child { + min-width: 16rem; + } +} + +.site-footer-note { + margin-top: 1.5rem; +} diff --git a/site/src/styles/theme.css b/site/src/styles/theme.css deleted file mode 100644 index f4b2d13..0000000 --- a/site/src/styles/theme.css +++ /dev/null @@ -1,358 +0,0 @@ -/* noctcore docs theme. - The noctcore brand: a night sky (near-black navy, not violet), a crescent moon - that runs from violet (#8b7cff) on one limb to cyan (#4dd0e1) on the other, - and a white wordmark over a short violet-to-cyan rule. Surfaces here are that - navy; the gradient is the signature and cyan does the link and highlight work. - Starlight is dark by default, so :root is the dark theme and - :root[data-theme='light'] overrides it. */ - -:root { - --sl-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; - --sl-font-mono: 'JetBrains Mono Variable', ui-monospace, 'SFMono-Regular', monospace; - --nc-font-display: 'Space Grotesk Variable', ui-sans-serif, system-ui, sans-serif; - - /* Brand constants. Decorative only (gradients, the moon): never body text. */ - --nc-violet: #8b7cff; - --nc-cyan: #4dd0e1; - --nc-gradient: linear-gradient(90deg, var(--nc-violet), var(--nc-cyan)); - --nc-night: #0b1020; - - /* Starlight maps text-accent to accent-high in dark mode: links, the - selected sidebar item and the primary button all use it, so it is the - brand cyan. accent (violet) drives hover borders and focus. */ - --sl-color-accent-low: #1b2450; - --sl-color-accent: #8b7cff; - --sl-color-accent-high: #5fd6e5; - - --sl-color-white: #eef2fb; - --sl-color-gray-1: #dde3f1; - --sl-color-gray-2: #b7c0d8; - --sl-color-gray-3: #939fbf; - --sl-color-gray-4: #5b6a90; - --sl-color-gray-5: #26324f; - --sl-color-gray-6: #141c33; - --sl-color-gray-7: #10172b; - --sl-color-black: #0b1020; - - /* Cyan as text: the card meta line. Same hue family as the links. */ - --nc-glow: #5fd6e5; - --nc-bad: #ff7b72; - --nc-bad-bg: rgba(255, 123, 114, 0.1); - --nc-good: #56d68f; - --nc-good-bg: rgba(86, 214, 143, 0.1); - --nc-card: #10172b; - --nc-card-hover: #161f3a; - - --sl-content-width: 50rem; -} - -:root[data-theme='light'] { - /* Light mode is genuinely light: white paper, navy ink, the violet darkened - until it holds 4.5:1 as link text, cyan deepened to teal for the same - reason. The gradient constants stay the brand hexes because they only - ever sit under navy text or on their own. */ - --sl-color-accent-low: #e7e5ff; - --sl-color-accent: #5a4ad6; - --sl-color-accent-high: #2a2470; - - --sl-color-white: #0b1020; - --sl-color-gray-1: #232c44; - --sl-color-gray-2: #3a4560; - --sl-color-gray-3: #5a6684; - --sl-color-gray-4: #7b86a4; - --sl-color-gray-5: #c9d1e3; - --sl-color-gray-6: #e9edf7; - --sl-color-gray-7: #f5f7fc; - --sl-color-black: #ffffff; - - --nc-glow: #0b6f7e; - --nc-bad: #c22f26; - --nc-bad-bg: rgba(194, 47, 38, 0.07); - --nc-good: #1a7f47; - --nc-good-bg: rgba(26, 127, 71, 0.07); - --nc-card: #f5f7fc; - --nc-card-hover: #eef1f9; -} - -/* Night sky. A faint violet and cyan glow at the top of the page in dark - mode, the way the banner lifts from black at the horizon. Light mode gets - none of this: paper stays paper. */ -:root:not([data-theme='light']) body { - background-image: - radial-gradient(70rem 30rem at 20% -8rem, rgba(139, 124, 255, 0.16), transparent 65%), - radial-gradient(50rem 24rem at 90% -10rem, rgba(77, 208, 225, 0.1), transparent 65%); - background-repeat: no-repeat; -} - -/* The wordmark is monospace white over a gradient rule in the banner; the - site title follows it. */ -.site-title { - font-family: var(--sl-font-mono); - font-weight: 600; - letter-spacing: -0.02em; - color: var(--sl-color-white) !important; -} - -.site-title span { - text-overflow: ellipsis; -} - -/* Below Starlight's 50em breakpoint the header has room for the logo, the - search and menu buttons and about 15rem of title. Monospace at h4 size - clips "plugins" on a 390px phone, base size fits. */ -@media (max-width: 50em) { - .site-title { - font-size: var(--sl-text-base); - } -} - -/* Hero: the banner's short violet-to-cyan rule under the title, and the - primary action carries the gradient with night-navy text, which holds AA - against both ends in both modes. */ -.hero h1::after { - content: ''; - display: block; - width: 6rem; - height: 0.25rem; - margin-top: 1rem; - border-radius: 999px; - background: var(--nc-gradient); -} - -/* Starlight centres the hero below 50rem and starts it above; the rule - follows the title. */ -@media (max-width: 49.99rem) { - .hero h1::after { - margin-inline: auto; - } -} - -.hero .sl-link-button.primary { - background: var(--nc-gradient); - border-color: transparent; - color: var(--nc-night); -} - -.hero .sl-link-button.primary:hover, -.hero .sl-link-button.primary:focus-visible { - color: var(--nc-night); - filter: brightness(1.08); -} - -.site-title, -.sl-markdown-content h1, -.sl-markdown-content h2, -h1#_top, -.hero h1 { - font-family: var(--nc-font-display); - letter-spacing: -0.015em; -} - -/* --------------------------------------------------------------------------- - Bad and good examples. - - The rule docs mark each fence `bad` or `good`; ec.config.mjs turns that into - .verdict-bad / .verdict-good on the rendered block. Three signals, so the - difference never rests on colour alone: a coloured rail down the left edge, - a coloured title tab, and a cross or tick glyph in front of the label. ---------------------------------------------------------------------------- */ - -.expressive-code .verdict-bad, -.expressive-code .verdict-good { - position: relative; -} - -.expressive-code .verdict-bad pre, -.expressive-code .verdict-good pre { - border-left-width: 4px !important; -} - -.expressive-code .verdict-bad pre { - border-left-color: var(--nc-bad) !important; -} - -.expressive-code .verdict-good pre { - border-left-color: var(--nc-good) !important; -} - -.expressive-code .verdict-bad .header .title, -.expressive-code .verdict-good .header .title { - font-weight: 600; -} - -.expressive-code .verdict-bad .header .title { - color: var(--nc-bad) !important; - background: var(--nc-bad-bg) !important; -} - -.expressive-code .verdict-good .header .title { - color: var(--nc-good) !important; - background: var(--nc-good-bg) !important; -} - -.expressive-code .verdict-bad .header .title::before { - content: '\2715\00a0\00a0'; -} - -.expressive-code .verdict-good .header .title::before { - content: '\2713\00a0\00a0'; -} - -/* --------------------------------------------------------------------------- - Generated rule tables. ---------------------------------------------------------------------------- */ - -.rule-table-wrap { - overflow-x: auto; -} - -.sl-markdown-content .rule-table { - display: table; - width: 100%; - font-size: var(--sl-text-sm); -} - -.sl-markdown-content .rule-table td:first-child { - width: 30%; -} - -.sl-markdown-content .rule-table td:first-child code { - overflow-wrap: anywhere; -} - -/* At phone width a 30% name column is under 100px and rule names wrap one - syllable per line. Give the name column a floor and let .rule-table-wrap - scroll sideways instead. The options tables in the rule docs have the same - problem in their last column ("Meaning"), so it gets a floor too. */ -@media (max-width: 40rem) { - .sl-markdown-content .rule-table td:first-child { - width: auto; - min-width: 11rem; - } - - .sl-markdown-content table:not(.rule-table) td:last-child { - min-width: 16rem; - } -} - -.pill { - display: inline-block; - padding: 0.05rem 0.5rem; - border-radius: 999px; - font-size: var(--sl-text-xs); - font-weight: 600; - white-space: nowrap; - border: 1px solid var(--sl-color-gray-4); - color: var(--sl-color-gray-2); -} - -.pill-error { - color: var(--sl-color-accent-high); - background: var(--sl-color-accent-low); - border-color: transparent; -} - -.pill-deprecated { - color: var(--sl-color-orange-high); - background: var(--sl-color-orange-low); - border-color: transparent; -} - -.deprecated-note { - display: block; - margin-top: 0.25rem; - font-size: var(--sl-text-xs); -} - -/* The all-rules index shows the full id (`noctcore-react/...`), which is - wider than a bare name: the first column gets more of the row. */ -.sl-markdown-content .rule-table-all td:first-child { - width: 36%; -} - -.rule-table-legend, -.quickstart-note { - font-size: var(--sl-text-sm); - color: var(--sl-color-gray-3); -} - -/* --------------------------------------------------------------------------- - Landing page. ---------------------------------------------------------------------------- */ - -.hero .tagline { - max-width: 38rem; -} - -.package-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); - gap: 0.75rem; - padding: 0 !important; - list-style: none; -} - -.package-grid .package-card { - margin: 0 !important; -} - -.package-card a { - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 0.4rem; - height: 100%; - padding: 1rem 1.1rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: 0.6rem; - background: var(--nc-card); - color: var(--sl-color-gray-2); - text-decoration: none; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.package-card a::before { - content: ''; - position: absolute; - inset: 0 0 auto 0; - height: 3px; - background: var(--nc-gradient); - opacity: 0; - transition: opacity 0.15s ease; -} - -.package-card a:hover, -.package-card a:focus-visible { - border-color: var(--sl-color-accent); - background: var(--nc-card-hover); -} - -.package-card a:hover::before, -.package-card a:focus-visible::before { - opacity: 1; -} - -.package-card-name { - font-family: var(--sl-font-mono); - font-size: var(--sl-text-sm); - font-weight: 600; - color: var(--sl-color-white); -} - -.package-card-desc { - font-size: var(--sl-text-sm); - line-height: 1.45; -} - -.package-card-meta { - margin-top: auto; - font-size: var(--sl-text-xs); - color: var(--nc-glow); -} - -.site-footer-note { - margin-top: 1.5rem; - font-size: var(--sl-text-xs); - color: var(--sl-color-gray-3); -}