Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
532a413
nav: size the sidebar group headers as headers, and indent their items
dimitrieh Sep 14, 2026
9b4db8c
docs nav: align the sidebar with the content column, indent items, dr…
dimitrieh Sep 14, 2026
5fd2eef
docs: bring the FlowFuse Expert question box to Nuxt pages
dimitrieh Sep 25, 2026
61588ab
docs: FlowFuse Expert on every docs page, and support prompts on the …
dimitrieh Sep 25, 2026
5fefab4
docs search: a dialog opened from the sidebar or with Cmd+K
dimitrieh Sep 25, 2026
7de5b73
header: docs search and Expert beside the menu on phones and tablets
dimitrieh Sep 25, 2026
67472fc
docs search: an X to close the dialog, no ring round the input, borde…
dimitrieh Sep 25, 2026
d869fb6
docs: breadcrumb on the docs home, Ask Expert under the table of cont…
dimitrieh Sep 25, 2026
fac5747
docs: the Expert box's brand flies into the modal header
dimitrieh Sep 25, 2026
82a4143
docs: Ask Docs, a floating button for FlowFuse Expert on every docs page
dimitrieh Sep 25, 2026
d71915c
docs: On this page lines up with the sidebar search and the breadcrumb
dimitrieh Sep 25, 2026
408ea31
header: a subtle grey line under the site header
dimitrieh Sep 25, 2026
8befa14
docs: Ask Docs wears FlowFuse Expert's colours
dimitrieh Sep 25, 2026
9303ebc
docs: a landing layout and the blocks a docs contents page is built from
dimitrieh Sep 25, 2026
d5efdec
docs: choices that explain each way in, and a two-column block
dimitrieh Sep 25, 2026
3e5587a
docs: a card leads somewhere through a plain markdown link
dimitrieh Sep 25, 2026
6b53ee2
docs: the Expert box and the card beside it are the same height
dimitrieh Sep 25, 2026
fde86ed
docs: no breadcrumb on the docs home
dimitrieh Sep 25, 2026
69ffd95
docs search: the field is there from the first paint
dimitrieh Sep 25, 2026
4512174
docs: the Expert box without prompt pills, and one card style throughout
dimitrieh Sep 25, 2026
899ded5
header: FlowFuse Docs on docs pages
dimitrieh Sep 25, 2026
af1d420
docs: the sidebar's first heading starts level with the page title
dimitrieh Sep 25, 2026
cfa92d8
header: the Docs lockup matched at 4x zoom, square mark until xl
dimitrieh Sep 25, 2026
f1e93de
docs: sidebar, logo and banner share one left edge at every width
dimitrieh Sep 25, 2026
6b294d9
header: one logo at every width, Docs one size and a step lighter
dimitrieh Sep 25, 2026
3a62fd4
docs: the Expert box spans its column again
dimitrieh Sep 25, 2026
49dbebb
docs: the docs home is this repo's page, not the flowfuse README
dimitrieh Sep 25, 2026
e950295
docs: each way in pairs its first step with a page about it
dimitrieh Sep 25, 2026
cd58eff
docs: the ways-in list opens smoothly and moves nothing around it
dimitrieh Sep 25, 2026
4d882f2
docs: Ask Docs moves to its own PR
dimitrieh Sep 29, 2026
9f91b7d
header: "Docs" beside the logo as a small superscript
dimitrieh Sep 29, 2026
22ea8ba
docs: source guides from a content-collection instead of copying them in
dimitrieh Sep 7, 2026
9e0f9b1
docs: keep resolving relative URLs once the guides are read in place
dimitrieh Sep 8, 2026
5967a80
docs: the docs home is the guides' index.md, read in place
dimitrieh Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ reference costs a page its whole Node help section with nothing failing, which i

**URL:** `/docs/{section}/{slug}/`
**Rendered by:** Nuxt — `nuxt/pages/docs/[...slug].vue` + `DocsLeftNav` component
**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build). Both sources are copied into it: `nuxt/lib/docs-sync.mjs` brings in the flowfuse tree and `nuxt/lib/guides-sync.mjs` overlays `nuxt/content-guides/` on top (stamping each guide with an `editUrl`), so `@nuxt/content` sees one `docs` collection. A guide edit therefore only reaches a running dev server once that overlay re-runs: `npm run dev:docs` is the watcher that does it, and without it an edit under `nuxt/content-guides/` shows up on the page only after a restart.
**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build) holds only the flowfuse tree, materialized by `nuxt/lib/docs-sync.mjs`. The guides are **not** copied into it: they are a second source of the `docs` collection, read straight out of `nuxt/content-guides/` (see `nuxt/content.config.ts`), so editing one shows up without a re-sync. `nuxt/lib/guides-sync.mjs` copies only their non-markdown assets, into `nuxt/public/docs/`, and fails the build on a path collision between the two sources.
**Local assets:** `nuxt/public/docs/` (images, etc.)

A page's browser title is `metaTitle || navTitle || title` (`nuxt/lib/docs-page-title.mjs`).
Expand Down
8 changes: 8 additions & 0 deletions nuxt/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,13 @@ dl.message-properties > dd {
@apply flex items-start justify-between gap-4 rounded-lg p-4 text-left;
}

/* The site-wide `code` rule draws a bordered, padded box, which inside the dark block reads
as a second box. Pages that sit in .nohero reset it already; docs pages do not. */
.ff-command__text {
@apply text-sm text-gray-100 whitespace-pre-wrap break-all;
border: 0;
border-radius: 0;
padding: 0;
}

.ff-command__copy {
Expand Down Expand Up @@ -999,8 +1004,11 @@ h4:hover .header-anchor::before {
font-weight: 400;
}

/* The grey line under the header is a shadow, not a border, so it adds nothing to the
height that --ff-header-height below has to match. */
.ff-website .ff-header {
@apply bg-white w-full px-6 py-4 sm:py-6 md:py-0 top-0 z-50 sticky;
box-shadow: 0 1px 0 var(--color-gray-200);
}

/* The header pins itself to the top of the viewport, so anything else that wants to
Expand Down
215 changes: 212 additions & 3 deletions nuxt/components/AlgoliaSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,58 @@ const props = withDefaults(defineProps<{
indexFilter?: string
placeholder?: string
sourceId?: string
/** Render a search button that opens the results in a dialog, at every screen width. */
detached?: boolean
/** Open it with Cmd+K / Ctrl+K and from a `ff-docs-search:open` window event. Needs `detached`. */
shortcut?: boolean
/** `lg` is the taller search field of a landing page's hero. Needs `detached`. */
size?: 'md' | 'lg'
}>(), {
placeholder: 'Search...',
sourceId: 'content',
detached: false,
shortcut: false,
size: 'md',
})

const isMac = ref(true)

// Autocomplete arrives from a CDN after the page has rendered. Until it has mounted, a
// detached search shows a stand-in button of the same size, so the field is there from the
// first paint instead of popping in; a click or Cmd+K before then opens the dialog as soon
// as it can.
const ready = ref(false)
let openWhenReady = false

function openDialog () {
if (!ready.value) {
openWhenReady = true
return
}
searchContainer.value?.querySelector<HTMLElement>('.aa-DetachedSearchButton')?.click()
}

function onKeydown (e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
openDialog()
}
}

onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('ff-docs-search:open', openDialog)
})

const searchContainer = ref<HTMLElement>()

onMounted(async () => {
if (props.detached && props.shortcut) {
isMac.value = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
window.addEventListener('keydown', onKeydown)
window.addEventListener('ff-docs-search:open', openDialog)
}

const loadScript = (src: string, integrity?: string): Promise<void> =>
new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return }
Expand Down Expand Up @@ -56,6 +100,16 @@ onMounted(async () => {
autocomplete({
container: searchContainer.value!,
placeholder: props.placeholder,
// An empty media query matches every width, so the input is always a button that
// opens the dialog: centred on wide screens, full screen on narrow ones (the theme's
// --aa-detached-modal-media-query decides which).
// The dialog carries its own class, so its styles below leave the other searches'
// phone dialogs alone. Its close button reads "Close" to a screen reader and shows an X.
...(props.detached ? {
detachedMediaQuery: '',
classNames: { detachedContainer: 'ff-search-dialog' },
translations: { detachedCancelButtonText: 'Close' },
} : {}),
getSources ({ query }: { query: string }) {
if (query !== prevQuery) {
prevQuery = query
Expand All @@ -81,9 +135,9 @@ onMounted(async () => {
return html`
<a href="#" data-href="${item.url}" class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemIcon aa-ItemIcon--alignTop">
${item.image ? html`<div class="aa-ItemIcon aa-ItemIcon--alignTop">
<img src="#" data-src="${item.image}" alt="${item.name}" width="40" height="40" />
</div>
</div>` : ''}
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">
${components.Highlight({ hit: item, attribute: ['hierarchy', 'lvl0'] })}
Expand Down Expand Up @@ -125,9 +179,164 @@ onMounted(async () => {
}
}
})

ready.value = true
if (openWhenReady) {
openWhenReady = false
// Autocomplete renders its button a frame or two after it is set up.
for (let i = 0; i < 30 && !searchContainer.value?.querySelector('.aa-DetachedSearchButton'); i++) {
await new Promise(resolve => requestAnimationFrame(resolve))
}
openDialog()
}
})
</script>

<template>
<div ref="searchContainer" id="algolia-search" class="border border-gray-200 rounded"></div>
<div class="ff-algolia" :class="{ 'ff-algolia--detached': detached, 'ff-algolia--lg': detached && size === 'lg' }">
<div ref="searchContainer" id="algolia-search" :class="detached ? '' : 'border border-gray-200 rounded'"></div>
<button v-if="detached && !ready" type="button" class="ff-algolia__standin" @click="openDialog">
<svg class="ff-algolia__standin-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<span>{{ placeholder }}</span>
</button>
<kbd v-if="detached && shortcut" class="ff-algolia__kbd" aria-hidden="true">{{ isMac ? '⌘' : 'Ctrl' }} K</kbd>
</div>
</template>

<style scoped>
.ff-algolia--detached {
position: relative;
}

/* The search button reads as a search field, with the shortcut shown at its right edge.
The id is in the selector because src/css/algolia-theme.css strips the border and padding
from `#algolia-search .aa-DetachedSearchButton`, and an id outranks the classes alone. */
.ff-algolia--detached :deep(#algolia-search .aa-DetachedSearchButton) {
width: 100%;
height: 2.5rem;
padding: 0 4rem 0 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font-size: 0.875rem;
color: #6b7280;
cursor: pointer;
}

.ff-algolia--detached :deep(#algolia-search .aa-DetachedSearchButton:hover) {
border-color: #a5b4fc;
}

/* Stands in for the search button until autocomplete has mounted (see `ready`). */
.ff-algolia__standin {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
height: 2.5rem;
padding: 0 4rem 0 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font-size: 0.875rem;
color: #6b7280;
text-align: left;
cursor: pointer;
}

.ff-algolia__standin-icon {
width: 1rem;
height: 1rem;
color: #777;
flex-shrink: 0;
}

.ff-algolia--lg .ff-algolia__standin {
height: 3rem;
padding-left: 1.25rem;
gap: 0.75rem;
border-radius: 8px;
font-size: 1rem;
}

.ff-algolia--lg :deep(#algolia-search .aa-DetachedSearchButton) {
height: 3rem;
padding-left: 0.75rem;
border-radius: 8px;
font-size: 1rem;
}

.ff-algolia__kbd {
position: absolute;
top: 50%;
right: 0.625rem;
transform: translateY(-50%);
pointer-events: none;
font-family: inherit;
font-size: 0.7rem;
line-height: 1;
color: #6b7280;
padding: 0.25rem 0.375rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
background: #f9fafb;
}
</style>

<style>
/* The dialog is appended to <body>, outside this component, so this is not scoped. */
.ff-search-dialog.aa-DetachedContainer--modal {
top: 12vh;
}

/* The input sits flat in the dialog header, above the header's own divider: no box and no
focus ring around it, since the caret already shows where typing goes. */
.ff-search-dialog .aa-Form,
.ff-search-dialog .aa-Form:focus-within {
border: 0;
box-shadow: none;
}

.ff-search-dialog .aa-SubmitIcon {
color: #6b7280;
}

/* The close X is the only X in the header, so the input's own clear button stays out. */
.ff-search-dialog .aa-ClearButton {
display: none;
}

.ff-search-dialog .aa-DetachedCancelButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
margin: auto 0 auto 0.25rem;
padding: 0;
border-radius: 6px;
color: #374151;
/* The word stays for screen readers; the X below is what shows. */
font-size: 0;
}

.ff-search-dialog .aa-DetachedCancelButton::before {
content: '';
width: 1.25rem;
height: 1.25rem;
background: currentColor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E") center / contain no-repeat;
}

.ff-search-dialog .aa-DetachedCancelButton:hover {
background: #f3f4f6;
}

.ff-search-dialog .aa-DetachedCancelButton:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
</style>
Loading
Loading