diff --git a/.github/actions/setup-docs/action.yml b/.github/actions/setup-docs/action.yml new file mode 100644 index 000000000..eb6f2a2a8 --- /dev/null +++ b/.github/actions/setup-docs/action.yml @@ -0,0 +1,35 @@ +name: Set up Concepta docs +description: Install the pinned docs toolchain and dependencies +inputs: + working-directory: + description: Directory containing the docs package and lockfile + default: apps/docs + theme-token: + description: Read-only token with access to conceptadev/docs-theme + required: true +runs: + using: composite + steps: + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.14.0' + - shell: bash + run: npm install --global pnpm@11.5.3 + - name: Install locked dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + DOCS_THEME_TOKEN: ${{ inputs.theme-token }} + run: | + if [ -z "$DOCS_THEME_TOKEN" ]; then + echo 'DOCS_THEME_TOKEN must have read access to conceptadev/docs-theme.' >&2 + exit 1 + fi + # Scope Git configuration to this process and its children. Credentials + # are never written to disk or included in the deployment artifact. + export GIT_CONFIG_COUNT=2 + export GIT_CONFIG_KEY_0='url.https://github.com/.insteadOf' + export GIT_CONFIG_VALUE_0='ssh://git@github.com/' + export GIT_CONFIG_KEY_1='credential.https://github.com.helper' + export GIT_CONFIG_VALUE_1='!f() { printf "%s\n" "username=x-access-token" "password=$DOCS_THEME_TOKEN"; }; f' + pnpm install --frozen-lockfile diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..4be8361b2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,79 @@ +name: Build publishable docs + +on: + workflow_dispatch: + inputs: + site-url: + description: Public HTTPS origin for the documentation + required: true + type: string + workflow_call: + inputs: + site-url: + description: Public HTTPS origin for the documentation + required: true + type: string + secrets: + DOCS_THEME_TOKEN: + description: Read-only access to the private Concepta docs theme + required: true + +permissions: + contents: read + +concurrency: + group: docs-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + env: + NEXT_TELEMETRY_DISABLED: '1' + NEXT_PUBLIC_SITE_URL: ${{ inputs.site-url }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Validate publishing origin + run: | + python3 - <<'PY' + import os + from urllib.parse import urlsplit + url = urlsplit(os.environ['NEXT_PUBLIC_SITE_URL']) + if (url.scheme != 'https' or not url.hostname or url.username or + url.password or url.path not in ('', '/') or url.query or url.fragment): + raise SystemExit('site-url must be an HTTPS origin without a path, credentials, query, or fragment.') + PY + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.0' + cache: true + - run: flutter pub get + - uses: ./.github/actions/setup-docs + with: + theme-token: ${{ secrets.DOCS_THEME_TOKEN }} + - name: Validate documentation sources + run: | + dart run tool/generate_fortal_catalog.dart --check + python3 tool/check_tutorial_assets.py + python3 -m unittest discover -s tool -p 'test_tutorial_assets.py' + - name: Build server and Flutter previews + working-directory: apps/docs + run: pnpm build + - name: Assemble deployment directory + working-directory: apps/docs + run: node scripts/package-server.mjs + - name: Smoke test the deployment artifact + working-directory: apps/docs + run: node scripts/test-server.mjs + - name: Archive deployment + working-directory: apps/docs + run: tar -czf remix-docs-server.tar.gz -C .next/standalone . + - uses: actions/upload-artifact@v4 + with: + name: remix-docs-server + path: apps/docs/remix-docs-server.tar.gz + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index 55baf4e71..dad4a9c50 100644 --- a/.gitignore +++ b/.gitignore @@ -293,3 +293,6 @@ app.*.symbols component_patterns_analysis.md /.cursor **/.venv/ + +# Local design review output +.impeccable/ diff --git a/apps/demo/lib/components/checkbox_group.dart b/apps/demo/lib/components/checkbox_group.dart new file mode 100644 index 000000000..0da7f3fa6 --- /dev/null +++ b/apps/demo/lib/components/checkbox_group.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_fortal/remix_fortal.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase(name: 'Interests', type: RemixCheckboxGroup) +Widget buildCheckboxGroupUseCase(BuildContext context) => + const CheckboxGroupExample(); + +class CheckboxGroupExample extends StatefulWidget { + const CheckboxGroupExample({super.key}); + + @override + State createState() => _CheckboxGroupExampleState(); +} + +class _CheckboxGroupExampleState extends State { + Set _values = {'Design'}; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + RemixCheckboxGroup( + values: _values, + onChanged: (values) => setState(() => _values = values), + semanticLabel: 'Interests', + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FortalCheckboxGroupItem(value: 'Design', label: 'Design'), + FortalCheckboxGroupItem(value: 'Code', label: 'Code'), + FortalCheckboxGroupItem( + value: 'Research', + label: 'Research', + enabled: false, + ), + ], + ), + ), + Text('Selected: ${_values.isEmpty ? 'none' : _values.join(', ')}'), + ], + ), + ), + ); +} diff --git a/apps/demo/lib/components/sidebar.dart b/apps/demo/lib/components/sidebar.dart new file mode 100644 index 000000000..a6179addf --- /dev/null +++ b/apps/demo/lib/components/sidebar.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_fortal/remix_fortal.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase(name: 'Workspace navigation', type: RemixSidebar) +Widget buildSidebarUseCase(BuildContext context) => const SidebarExample(); + +class SidebarExample extends StatefulWidget { + const SidebarExample({super.key}); + + @override + State createState() => _SidebarExampleState(); +} + +class _SidebarExampleState extends State { + String _selected = 'Overview'; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: SizedBox( + width: 256, + height: 320, + child: FortalSidebar( + sections: const [ + RemixSidebarSection( + label: 'Workspace', + destinations: [ + RemixSidebarDestination( + value: 'Overview', + label: 'Overview', + icon: Icons.space_dashboard_outlined, + ), + RemixSidebarDestination( + value: 'Settings', + label: 'Settings', + icon: Icons.settings_outlined, + ), + RemixSidebarDestination( + value: 'Billing', + label: 'Billing', + icon: Icons.credit_card, + enabled: false, + ), + ], + ), + ], + selectedValue: _selected, + onSelected: (value) => setState(() => _selected = value), + semanticLabel: 'Workspace navigation', + footer: Text('Selected: $_selected'), + ), + ), + ), + ); +} diff --git a/apps/demo/lib/components/skeleton.dart b/apps/demo/lib/components/skeleton.dart new file mode 100644 index 000000000..bfa0ce40f --- /dev/null +++ b/apps/demo/lib/components/skeleton.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_fortal/remix_fortal.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase(name: 'Loading content', type: RemixSkeleton) +Widget buildSkeletonUseCase(BuildContext context) => const SkeletonExample(); + +class SkeletonExample extends StatefulWidget { + const SkeletonExample({super.key}); + + @override + State createState() => _SkeletonExampleState(); +} + +class _SkeletonExampleState extends State { + bool _loading = true; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + FortalSkeleton( + loading: _loading, + child: const Text('Your workspace is ready'), + ), + FortalButton.outline( + label: _loading ? 'Show content' : 'Show skeleton', + onPressed: () => setState(() => _loading = !_loading), + ), + ], + ), + ), + ); +} diff --git a/apps/demo/lib/main.directories.g.dart b/apps/demo/lib/main.directories.g.dart index 71fce2c33..ba05e8342 100644 --- a/apps/demo/lib/main.directories.g.dart +++ b/apps/demo/lib/main.directories.g.dart @@ -17,6 +17,8 @@ import 'package:demo/components/button.dart' as _demo_components_button; import 'package:demo/components/callout.dart' as _demo_components_callout; import 'package:demo/components/card.dart' as _demo_components_card; import 'package:demo/components/checkbox.dart' as _demo_components_checkbox; +import 'package:demo/components/checkbox_group.dart' + as _demo_components_checkbox_group; import 'package:demo/components/code.dart' as _demo_components_code; import 'package:demo/components/data_list.dart' as _demo_components_data_list; import 'package:demo/components/data_table.dart' as _demo_components_data_table; @@ -35,6 +37,8 @@ import 'package:demo/components/radio.dart' as _demo_components_radio; import 'package:demo/components/segmented_control.dart' as _demo_components_segmented_control; import 'package:demo/components/select.dart' as _demo_components_select; +import 'package:demo/components/sidebar.dart' as _demo_components_sidebar; +import 'package:demo/components/skeleton.dart' as _demo_components_skeleton; import 'package:demo/components/slider.dart' as _demo_components_slider; import 'package:demo/components/spinner.dart' as _demo_components_spinner; import 'package:demo/components/switch.dart' as _demo_components_switch; @@ -197,6 +201,15 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'RemixCheckboxGroup', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Interests', + builder: _demo_components_checkbox_group.buildCheckboxGroupUseCase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'RemixDataList', useCases: [ @@ -342,6 +355,24 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'RemixSidebar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Workspace navigation', + builder: _demo_components_sidebar.buildSidebarUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'RemixSkeleton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Loading content', + builder: _demo_components_skeleton.buildSkeletonUseCase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'RemixSlider', useCases: [ diff --git a/apps/demo/test/docs_preview_test.dart b/apps/demo/test/docs_preview_test.dart new file mode 100644 index 000000000..c8a94ff6c --- /dev/null +++ b/apps/demo/test/docs_preview_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:demo/components/checkbox_group.dart'; +import 'package:demo/components/sidebar.dart'; +import 'package:demo/components/skeleton.dart'; +import 'package:demo/main.directories.g.dart'; +import 'package:demo/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix_fortal/remix_fortal.dart'; +import 'package:widgetbook/widgetbook.dart'; + +void main() { + test('preview theme query matches the catalog addon contract', () { + final addon = FortalThemeAddon(); + expect(addon.groupName, 'theme'); + for (final name in ['light', 'dark']) { + expect(addon.valueFromQueryGroup({'name': name}).name, name); + } + }); + + test('every documentation component maps to real Widgetbook routes', () { + final root = WidgetbookRoot(children: directories); + final routes = root.leaves + .whereType() + .map((node) => node.path) + .toSet(); + final manifest = + jsonDecode( + File('../../docs/component-previews.json').readAsStringSync(), + ) + as Map; + final pages = Directory('../../docs/components') + .listSync() + .whereType() + .map((file) => file.uri.pathSegments.last.replaceAll('.mdx', '')) + .toSet(); + expect(manifest.keys.toSet(), pages); + for (final entry in manifest.entries) { + expect(File('lib/components/${entry.key}.dart').existsSync(), isTrue); + final cases = entry.value['cases'] as List; + expect(cases, isNotEmpty); + for (final example in cases) { + expect( + routes, + contains(example['path']), + reason: '${entry.key}: ${example['name']}', + ); + } + } + }); + + Future show(WidgetTester tester, Widget child) async { + await tester.pumpWidget(MaterialApp(home: FortalScope(child: child))); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + + testWidgets( + 'checkbox example changes its controlled set and keeps research disabled', + (tester) async { + await show(tester, const CheckboxGroupExample()); + await tester.tap(find.text('Code')); + await tester.pump(); + expect(find.text('Selected: Design, Code'), findsOneWidget); + await tester.tap(find.text('Research')); + await tester.pump(); + expect(find.text('Selected: Design, Code'), findsOneWidget); + }, + ); + + testWidgets('sidebar example changes selection and keeps billing disabled', ( + tester, + ) async { + await show(tester, const SidebarExample()); + await tester.tap(find.text('Settings')); + await tester.pump(); + expect(find.text('Selected: Settings'), findsOneWidget); + await tester.tap(find.text('Billing')); + await tester.pump(); + expect(find.text('Selected: Settings'), findsOneWidget); + }); + + testWidgets('skeleton example can reveal its content', (tester) async { + await show(tester, const SkeletonExample()); + expect( + tester.widget(find.byType(FortalSkeleton)).loading, + isTrue, + ); + await tester.tap(find.text('Show content')); + await tester.pump(); + expect( + tester.widget(find.byType(FortalSkeleton)).loading, + isFalse, + ); + expect(find.text('Show skeleton'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/apps/demo/web/index.html b/apps/demo/web/index.html index f70557de6..a83f9fd79 100644 --- a/apps/demo/web/index.html +++ b/apps/demo/web/index.html @@ -34,6 +34,14 @@ + diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 000000000..373c60b21 --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.next/ +.source/ +.generated/ +public/assets/ +public/previews/ +*.tsbuildinfo +next-env.d.ts +remix-docs-server.tar.gz diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 000000000..ffb0dd5be --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,143 @@ +# Remix Fumadocs app + +This app renders the canonical `../../docs` content with the shared Concepta +Fumadocs theme. It does not contain a second editable copy of the documentation. + +## Run + +Use Node 24.14+, pnpm 11.5.3, Python 3.9+, and FVM with the repository's +pinned Flutter SDK. Run `fvm flutter pub get` in `apps/demo` first: + +```bash +cd apps/docs +pnpm install --frozen-lockfile +pnpm dev +``` + +Open `http://localhost:3000/tutorials/settings-screen`. Production checks: + +```bash +pnpm typecheck +pnpm build +pnpm start +``` + +Set `NEXT_PUBLIC_SITE_URL` to the deployment origin before building metadata. +This is a server deployment: `/api/search` uses Fumadocs search, prioritizing +page titles and frontmatter keywords while retaining section links. +Static export and deployment under a path prefix are not configured. + +With the server running, verify component discovery with +`node --test scripts/test-search.mjs`. Set `DOCS_TEST_URL` for a different origin. + +## Reusable build and publishing + +`.github/actions/setup-docs` installs the pinned Node/pnpm toolchain and the +locked theme dependency. `.github/workflows/docs.yml` can be called from another +workflow with `workflow_call` or run manually with `workflow_dispatch`. +It validates sources, builds Flutter previews and the Next.js server, tests an +isolated deployment copy, and uploads `remix-docs-server` as a tar archive. + +Before running the workflow: + +- Configure `DOCS_THEME_TOKEN` as an Actions secret with read-only contents + access to `conceptadev/docs-theme`. The default Remix `GITHUB_TOKEN` cannot + read that separate private repository. Credentials are scoped to dependency + installation and are not saved in the artifact. +- Supply `site-url` as the final HTTPS origin. Canonical/social URLs are baked + into the build, so rebuild when the public origin changes. + +The artifact supports a root-path Node server deployment. Extract it on a +Node 24.14+ host and run `HOSTNAME=0.0.0.0 PORT=3000 node server.js` behind the +host's HTTPS endpoint. It contains public assets, Flutter previews, Next client +chunks, and traced server dependencies; no install or Flutter SDK is needed +on the host. Linux CI produces the artifact intended for a Linux host. + +Local artifact verification after `pnpm build`: + +```bash +node scripts/package-server.mjs +node scripts/test-server.mjs +``` + +The workflow prepares an artifact; it does not provision hosting or publish a +site. Choose/configure the host before deploying. It is deliberately manual +while the theme is private: pull-request builds must not receive cross-repo +credentials for untrusted code. The existing GitHub Pages workflow continues +to publish the Flutter showcases and cannot serve this Next.js server app. + +## Theme access and release limitation + +The theme is not published on npm yet. `package.json` and the lockfile pin the +reviewed `conceptadev/docs-theme` commit +`0f032d0efb437a2aface4e3198a708549ef39671`, the merged theme `main`. pnpm +fetches this GitHub dependency over SSH and builds it; only this exact Git +source is allowed to run scripts. +Installation requires GitHub access to that currently internal repository. +No theme source or credentials are vendored here. + +The locked theme works locally and in the authenticated workflow above; +anonymous installation is not supported. Once the theme's license and publication are resolved, replace the +Git dependency with its verified npm release, regenerate the lockfile, and +repeat the production/browser checks. Do not add a token to this repository +or bypass access controls to make the dependency resolve. + +## Content ownership + +Edit MDX and assets under `../../docs`; edit navigation in `../../docs.json`. +That JSON file contains only `sidebar`; site settings live in `docs.config.ts` +and colors live in `app/global.css`. Component categories use `collapsible: true`. +The Fortal catalog generator reuses those categories; regenerate it with +`melos docs:catalog` after changing their membership or names. +`pnpm content` validates evidence, stages generated MDX in `.generated/content`, +and copies assets into `public/assets`. These directories are disposable and +gitignored. Restart `pnpm dev` after editing canonical content to stage changes. + +`TutorialSource` is a build-time include, not a runtime React component. The +four source panels read the exact Dart files from the checked sample ZIP and +become native Fumadocs fenced blocks. Fumadocs owns highlighting, copying, +search, navigation, and responsive layout. The small MDX compatibility map +retains existing `Info`, `Note`, `Warning`, and single-example `CodeGroup` tags. + +The retired standalone tutorial and guide URLs redirect to their native MDX +replacements. Old HTML files and their renderer dependencies remain recoverable +from Git history, not shipped alongside the new app. + +## Live component examples + +Every component page pairs a live Widgetbook preview with its real catalog +source. `docs/component-previews.json` selects the generated Widgetbook routes; +`tool/prepare_docs_site.py` includes `apps/demo/lib/components/.dart` +directly. Edit these sources, not the generated MDX. Source panels are catalog +examples with Fortal styling, not standalone applications. + +The embedded example follows the site's light/dark theme so a dark page never +frames a white canvas. The Theme control still overrides it for comparison, and +that explicit choice then persists across site theme changes. +Reset example restores the first example, follows the site theme again, and +restarts the embedded Flutter app. + +Both `pnpm dev` and `pnpm build` build that same Flutter catalog into ignored +`public/previews`. `/previews/` opens the full catalog; embedded routes use +Widgetbook's native preview mode. No separate renderer or remote deployment +is required. To iterate on docs alone after the initial build, run `pnpm content` +and `pnpm exec next dev`. Rebuild previews after changing Flutter examples. + +`apps/demo/test/docs_preview_test.dart` checks page coverage, actual generated +routes, and the new interactive examples. Run it from `apps/demo` with +`fvm flutter test test/docs_preview_test.dart test/catalog_test.dart`. + +The build uses the current Git commit for page-source links. Set +`DOCS_SOURCE_REF` explicitly when building without a Git checkout. Configure +`NEXT_PUBLIC_SITE_URL` separately for canonical metadata. + +## Styling + +`app/global.css` loads Tailwind, the Fumadocs `preset.css`, and the theme's +`theme.css`, in that order. The Concepta payload is a complete Fumadocs color +preset, so `fumadocs-ui/css/neutral.css` must not be stacked underneath it. +Shared fonts, reading styles, and logo support belong in `conceptadev/docs-theme`. +Remix keeps only its own identity in `.remix-docs`: its existing artwork, the +green accent through `--docs-primary-light`/`--docs-primary-dark`, and the +Flutter preview styles. The theme documents its Concepta design-system +reference; private design-system source and assets are not copied here. diff --git a/apps/docs/app/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx new file mode 100644 index 000000000..82045a54b --- /dev/null +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -0,0 +1,27 @@ +import { createPageMetadata, createSourceUrl } from '@conceptadev/docs-theme'; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import { docsConfig } from '@/docs.config'; +import { source } from '@/lib/source'; + +export default async function Page({ params }: PageProps<'/[[...slug]]'>) { + const page = source.getPage((await params).slug); + if (!page) notFound(); + const MDX = page.data.body; + return item.depth <= (page.url === '/fortal/catalog' ? 2 : 3))} full={page.data.full}> +
+ {page.data.title} + {page.data.description} +
+ + View page source on GitHub +
; +} +export function generateStaticParams() { return source.generateParams(); } +export async function generateMetadata({ params }: PageProps<'/[[...slug]]'>) { + const page = source.getPage((await params).slug); + if (!page) notFound(); + return createPageMetadata(docsConfig, { title: page.data.title, description: page.data.description, path: page.url }); +} diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts new file mode 100644 index 000000000..6686c36e5 --- /dev/null +++ b/apps/docs/app/api/search/route.ts @@ -0,0 +1,42 @@ +import { createFromSource, initSimpleSearch } from 'fumadocs-core/search/server'; +import { source } from '@/lib/source'; + +// Page discovery comes before section matches, so a long guide cannot bury +// the component whose name the reader entered. Keep deep links underneath. +const pages = initSimpleSearch({ + indexes: source.getPages().map(page => ({ + title: page.data.title, + keywords: page.data.keywords.join(' '), + content: '', + url: page.url, + })), + search: { boost: { title: 8, keywords: 2 }, tolerance: 0 }, +}); +const sections = createFromSource(source, { + search: { groupBy: { properties: ['page_id'], maxResult: 3 } }, +}); + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams; + const query = params.get('query')?.trim(); + if (!query) return Response.json([]); + const requestedLimit = Number(params.get('limit') ?? 20); + const limit = Number.isInteger(requestedLimit) + ? Math.max(0, Math.min(requestedLimit, 60)) : 20; + const [pageResults, sectionResults] = await Promise.all([ + pages.search(query, { limit }), + sections.search(query, { limit: 60 }), + ]); + const promoted = new Set(pageResults.map(result => result.url)); + const ordered = pageResults.flatMap(page => [ + page, + ...sectionResults.filter(result => result.type !== 'page' && result.url.split('#')[0] === page.url), + ]); + ordered.push(...sectionResults.filter(result => !promoted.has(result.url.split('#')[0]))); + const seen = new Set(); + return Response.json(ordered.filter(result => { + if (seen.has(result.url)) return false; + seen.add(result.url); + return true; + }).slice(0, limit)); +} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css new file mode 100644 index 000000000..e9ddf3153 --- /dev/null +++ b/apps/docs/app/global.css @@ -0,0 +1,72 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/preset.css'; +@import '@conceptadev/docs-theme/theme.css'; + +/* Product identity stays here; the shared theme owns reading/layout rules. */ +.remix-docs { + /* Derived from the brand green for light surfaces. #087a0a only reached + 4.17:1 on muted and 4.49:1 on secondary; this clears AA on all four. */ + --docs-primary-light: #066b08; + --docs-primary-dark: #00eb03; + + /* Fumadocs reads these as `var(--color-fd-)` from the callout's inline + style, never through a utility, so Tailwind prunes them out of the theme's + `@theme` block and every callout falls back to `--color-fd-muted`. Declared + here they survive, and they carry Concepta's support ramp rather than + Fumadocs' generic defaults. */ + --color-fd-info: #0c31ff; + --color-fd-success: #006f50; + --color-fd-warning: #e97000; + --color-fd-error: #c62828; + --color-fd-idea: #e97000; +} +.remix-docs.dark { + --color-fd-info: #7b9fff; + --color-fd-success: #1fe0b8; + --color-fd-warning: #ffc04d; + --color-fd-error: #ff6b6b; + --color-fd-idea: #ffc04d; +} +.remix-docs [role="tabpanel"]:has(> .remix-preview) { padding: 0; border-radius: 0; } +.remix-preview { margin: 0; } +.remix-preview-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 6.5rem auto; align-items: end; gap: 0.75rem; padding: 1rem; border-bottom: 1px solid var(--color-fd-border); font-size: 0.875rem; } +.remix-preview-field { display: grid; gap: 0.375rem; min-width: 0; } +.remix-preview-field label { color: var(--color-fd-muted-foreground); } +.remix-preview-select { position: relative; } +.remix-preview-select select { appearance: none; width: 100%; min-height: 2.75rem; padding: 0.5rem 2.5rem 0.5rem 0.75rem; background: var(--color-fd-background); border: 1px solid var(--color-fd-border); border-radius: 0.375rem; color: var(--color-fd-foreground); font: inherit; text-overflow: ellipsis; cursor: pointer; } +.remix-preview-select svg { position: absolute; inset-inline-end: 0.75rem; top: 50%; transform: translateY(-50%); width: 1rem; height: 1rem; pointer-events: none; } +.remix-preview-select select:hover { border-color: var(--color-fd-muted-foreground); } +.remix-preview-actions { display: flex; align-items: center; gap: 0.25rem; } +.remix-preview-actions :is(a, button) { display: inline-flex; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0.5rem 0.75rem; border-radius: 0.375rem; white-space: nowrap; color: var(--color-fd-muted-foreground); font: inherit; cursor: pointer; text-decoration: none; } +@media (hover: hover) { + .remix-preview-actions :is(a, button):hover { background: var(--color-fd-accent); color: var(--color-fd-accent-foreground); } +} +.remix-preview :is(select, button, a):focus-visible { outline: 2px solid var(--color-fd-primary); outline-offset: 2px; } +@media (prefers-reduced-motion: no-preference) { + .remix-preview-actions :is(a, button), .remix-preview-select select { transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out; } + .remix-preview-stage iframe { transition: opacity 120ms ease-out; } +} +@media (max-width: 640px) { + .remix-preview-toolbar { grid-template-columns: minmax(0, 1fr) 6.5rem; } + .remix-preview-actions { grid-column: 1 / -1; justify-content: space-between; } +} +.remix-preview-stage { position: relative; height: 400px; background: var(--color-fd-background); } +.remix-preview-stage-large { height: 560px; } +.remix-preview-stage-compact { height: 320px; } +.remix-preview-stage:not([data-status="ready"]) iframe { opacity: 0; } +.remix-search-button { min-height: 2.75rem; padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-fd-primary); color: var(--color-fd-primary-foreground); font-weight: 600; cursor: pointer; } +.remix-search-button:hover { background: var(--color-fd-primary); filter: brightness(0.9); } +.remix-catalog-search { display: grid; gap: 0.5rem; margin-block: 2rem; } +.remix-catalog-search label { font-weight: 600; } +.remix-catalog-search input { min-height: 2.75rem; padding: 0.5rem 0.75rem; border: 1px solid var(--color-fd-border); border-radius: 0.375rem; background: var(--color-fd-background); color: var(--color-fd-foreground); } +.remix-catalog-search input::placeholder { color: var(--color-fd-muted-foreground); } +.remix-catalog-search p { color: var(--color-fd-muted-foreground); font-size: 0.875rem; } +.remix-catalog-search ul { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; } +.remix-catalog-search a, .remix-catalog-search button { display: inline-flex; align-items: center; min-height: 2.75rem; text-decoration: underline; text-underline-offset: 0.2em; cursor: pointer; } +.remix-catalog-search button { justify-self: start; } +.remix-docs :is(.remix-search-button, .remix-preview-reset, .remix-catalog-search input, .remix-catalog-search button, .remix-catalog-search a):focus-visible { outline: 2px solid var(--color-fd-primary); outline-offset: 3px; } +.remix-preview-stage iframe { width: 100%; height: 100%; border: 0; } +.remix-preview-status { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem; text-align: center; background: var(--color-fd-background); } +.remix-preview-status button { min-height: 2.75rem; padding: 0.5rem 1rem; border-radius: 0.375rem; background: var(--color-fd-primary); color: var(--color-fd-primary-foreground); font-weight: 600; } +.remix-preview-caption { padding: 0.75rem 1rem; border-top: 1px solid var(--color-fd-border); color: var(--color-fd-muted-foreground); font-size: 0.875rem; line-height: 1.5; } +.remix-preview-caption a { text-decoration: underline; text-underline-offset: 0.2em; } diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx new file mode 100644 index 000000000..b953c7b86 --- /dev/null +++ b/apps/docs/app/layout.tsx @@ -0,0 +1,29 @@ +import { createSiteMetadata, createBaseLayoutOptions } from '@conceptadev/docs-theme'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { RootProvider } from 'fumadocs-ui/provider/next'; +import type { ReactNode } from 'react'; +import { docsConfig } from '@/docs.config'; +import { source } from '@/lib/source'; +import './global.css'; + +export const metadata = { + ...createSiteMetadata(docsConfig), + icons: { icon: '/assets/favicon.png' }, +}; +const layoutOptions = createBaseLayoutOptions(docsConfig); +// The catalog is a separate Flutter app, even when served on the docs origin. +layoutOptions.links = layoutOptions.links?.map(link => + 'url' in link && link.url === '/previews/' ? { ...link, external: true } : link, +); +export default function RootLayout({ children }: { children: ReactNode }) { + return + + {/* No sidebar banner: the wordmark sits directly above it, and a + "Documentation" label there reads as a heading for the quick links + below it, which it does not head. */} + + {children} + + + ; +} diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx new file mode 100644 index 000000000..b4633cc62 --- /dev/null +++ b/apps/docs/app/not-found.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; +import { SearchButton } from '@/components/search-button'; + +export default function NotFound() { + return +
+ Page not found + We couldn’t find a documentation page at this address. +
+ +

Search for a component or guide, or start with one of these pages.

+ +
    +
  • Remix introduction
  • +
  • Install Remix and render your first button
  • +
  • Browse the Fortal catalog
  • +
+
+
; +} diff --git a/apps/docs/components/catalog-search.tsx b/apps/docs/components/catalog-search.tsx new file mode 100644 index 000000000..10531b05e --- /dev/null +++ b/apps/docs/components/catalog-search.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useId, useState } from 'react'; + +export function CatalogSearch({ items }: { items: string[] }) { + const id = useId(); + const [query, setQuery] = useState(''); + const matches = items.filter(item => item.toLowerCase().includes(query.trim().toLowerCase())); + return
+ + setQuery(event.target.value)} aria-describedby={`${id}-status`} /> +

{query.trim() + ? matches.length + ? `${matches.length} ${matches.length === 1 ? 'widget' : 'widgets'} found. Choose a result to jump to its API.` + : 'No widgets match. Try a shorter name or clear your search.' + : 'Search by name, or browse the categories below.'}

+ {query.trim() && (matches.length ?
    + {matches.map(item =>
  • {item}
  • )} +
: )} +
; +} diff --git a/apps/docs/components/flutter-preview.tsx b/apps/docs/components/flutter-preview.tsx new file mode 100644 index 000000000..14ed218cb --- /dev/null +++ b/apps/docs/components/flutter-preview.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useTheme } from 'fumadocs-ui/provider/base'; +import { useEffect, useId, useRef, useState, type ComponentProps } from 'react'; + +// Native selection retains keyboard navigation and the mobile system picker. +function PreviewSelect({ label, ...props }: ComponentProps<'select'> & { label: string }) { + return
+ +
+