Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 10 additions & 8 deletions docs/backend/llm.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,19 @@ to `+page.svx` instead of in its `<script>` block. The page imports that module
docs mirror routes import the same module and pass it to `componentsToMarkdown`
(`src/lib/shared/utils/llm/components.ts`), which swaps component tags for plain Markdown: an
`AdaptiveList` (and its `mb-5` spacing wrapper) becomes a Markdown table, a `CodeBlock` becomes a
fenced block per tab under its title, a `FileTree` becomes a plain-text tree like the `tree` command
prints, a `Screenshots` becomes each image with its caption, and a `Callout` becomes a blockquote
led by its type. Columns are typed as `MarkdownColumn`, whose optional `markdown(row)` formats a
cell for the mirror, so styling the page adds in snippets (badges, links, placeholders) has a text
form. A tag that names data the module doesn't export, and any other component, stays as it is.
fenced block per tab under its title (a `marks` block gets a line after it saying the square
brackets mark matched text), a `FileTree` becomes a plain-text tree like the `tree` command prints,
a `Screenshots` becomes each image with its caption, and a `Callout` becomes a blockquote led by its
type. Columns are typed as `MarkdownColumn`, whose optional `markdown(row)` formats a cell for the
mirror, so styling the page adds in snippets (badges, links, placeholders) has a text form. A tag
that names data the module doesn't export, and any other component, stays as it is.

Images and videos are serialized in every article mirror, docs, dev logs, and wiki alike, by
`mediaToMarkdown` in the same file, since it needs no page data: a `ThemeImage` becomes
`![alt](light image)`, so a model can read the alt text or fetch the image, and a `Video` becomes
its title and `description` followed by a link to the file. The `require-media-alt` lint rule makes
sure that text exists.
`![alt](light image)` followed by its caption, so a model can read the alt text or fetch the image,
a `Video` becomes its title and `description` followed by a link to the file, a `Dimensions`
animation becomes its `description`, and an `InlineIcon` becomes its `label`. The
`require-media-alt` lint rule makes sure that text exists.

A docs page with `next` frontmatter ends its mirror with a `## Next` list of those pages, each a
link with its blurb, matching the footer on the web page.
Expand Down
78 changes: 78 additions & 0 deletions docs/frontend/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,20 @@ by default; `overflow="wrap"` wraps them at spaces instead. A wrapped line conti
indentation, or just after the comment marker and its space when the line has a comment, so a long
comment reads as one aligned block. `wrapIndent` in `indent.ts` works out that column per line.

With `marks`, `[text]` in the code renders as a `<mark>` in the success tokens, square-cornered,
without the brackets, for showing what a pattern matched in a list of strings. Marked code renders
as plain text and skips Shiki, and the copy button copies the code without the brackets.
`markSegments` and `stripMarks` in `marks.ts` do the splitting. The Markdown mirror ships the
brackets as written and adds a line after the block saying what they mean.

| Prop | Type | Required | Default |
| --------------- | -------------------- | -------- | ---------- |
| `items` | `CodeItem[]` | yes | |
| `overflow` | `'scroll' \| 'wrap'` | no | `'scroll'` |
| `marks` | `boolean` | no | `false` |
| `footer` | `Snippet<[number]>` | no | |
| `headerActions` | `Snippet` | no | |

#### `FileTree`

`src/lib/client/ui/markdown/tree/FileTree.svelte`
Expand Down Expand Up @@ -874,10 +888,16 @@ driven by the `--theme-image-*` tokens each theme declares; the component never
| `dark` | `string` | yes | |
| `light` | `string` | yes | |
| `alt` | `string` | yes | |
| `caption` | `string` | no | |
| `border` | `boolean` | no | `false` |
| `preview` | `boolean` | no | `true` |
| `onpreview` | `() => void` | no | |

`caption` shows centered muted text below the image, and below the image in the preview. Backticks
mark inline code, as in Markdown, so the Markdown mirror can pass the caption through unchanged.
`Screenshots` renders its captions this way too. A captioned image renders as a `<figure>`, so it
can't sit inside a paragraph; one without a caption stays inline.

`border` outlines the image with the border token. Profilarr's UI uses the same background colors as
this site, so screenshots of it blend into the page without one.

Expand Down Expand Up @@ -964,6 +984,64 @@ mirrors. The `require-media-alt` lint rule requires it on every authored page.
description="What happens in the clip, described as fully as possible." />
```

#### `Dimensions`

`src/lib/client/ui/markdown/dimensions/Dimensions.svelte`

An animated SVG of a shape gaining a dimension, for the testing pages: regular expressions test
along a line, custom formats across a square, and quality profiles through a cube. `stage` sets what
it draws, and each stage starts where the one before it ends: `1` draws a dot out into a line, `2`
sweeps the line into a square, and `3` turns the camera and extrudes the square into a cube. The
geometry lives in `geometry.ts` next to it: a box whose width, height, and depth grow from zero,
projected through a camera that can turn, with faces and edges that point away from the camera drawn
as hidden.

Edges and dots use `accent-solid`, faces `accent-bg`, and hidden edges and dots `border`, so it
follows the theme. As the camera turns in stage 3, the dots fade into spheres: a radial gradient
from the accent mixed toward white to the accent mixed toward black, lit from the top left. The
shape is drawn at the same scale on every page, so the line, square, and cube share an edge length.
Prerendered HTML shows the finished shape; after hydration the animation plays once, when the figure
scrolls into view. With reduced motion, it stays on the finished shape. `description` is its
accessible name and replaces it in the Markdown mirrors, and the `require-media-alt` lint rule
requires it.

`from` plays every stage from that one up to `stage`, pausing briefly between them, so
`from={1} stage={3}` runs the whole sequence from dot to cube. `loop` plays it forwards, holds,
plays it backwards, and repeats, pausing while the figure is out of view.

| Prop | Type | Required | Default |
| ------------- | ------------- | -------- | ------- |
| `stage` | `1 \| 2 \| 3` | yes | |
| `from` | `1 \| 2 \| 3` | no | `stage` |
| `loop` | `boolean` | no | `false` |
| `description` | `string` | yes | |

```svelte
<Dimensions
stage={1}
description="A dot stretches out into a horizontal line." />
```

#### `InlineIcon`

`src/lib/client/ui/markdown/icon/InlineIcon.svelte`

A Lucide icon inside a sentence, for naming a Profilarr control by the icon it shows, like the
sliders on the profile menu. `icon` picks one from `INLINE_ICONS` in `icons.ts` next to it, so a
page can only use icons added there, and an unknown name fails the build. The icon is `1em` square
and takes the text color, so it matches the text around it. It renders as a `<span>`, so it can sit
inside a paragraph. `label` is its accessible name and replaces it in the Markdown mirrors, and the
`require-media-alt` lint rule requires it.

| Prop | Type | Required | Default |
| ------- | ---------------- | -------- | ------- |
| `icon` | `InlineIconName` | yes | |
| `label` | `string` | yes | |

```md
Choose a profile from the profile menu (<InlineIcon icon="sliders" label="sliders icon" />).
```

## Semantic Tokens

All tokens use the `--theme-*` prefix. Components never use raw color values. Tailwind utilities are
Expand Down
3 changes: 2 additions & 1 deletion docs/tooling/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ architecture and the list semantics.

Every image and video in an authored page (`src/routes/**/*.svx`) needs a text version: `ThemeImage`
and Markdown images need `alt` text of at least 15 characters, and `Video` needs a `description` of
at least 50. Screen readers read the text, and the Markdown mirrors replace the media with it (see
at least 50. `InlineIcon` needs a `label`, of any length, since it stands in for a word or two.
Screen readers read the text, and the Markdown mirrors replace the media with it (see
[backend/llm.md](../backend/llm.md)). The minimums only catch placeholders like `alt="cf"`; they
can't judge whether a description is good. Reports the line each tag starts on. Reads source files,
so it doesn't need a build.
Expand Down
29 changes: 26 additions & 3 deletions src/lib/client/ui/markdown/code/CodeBlock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import Button from '$lib/client/ui/button/Button.svelte';
import { highlight } from './highlight.js';
import { wrapIndents } from './indent.js';
import { markSegments, stripMarks } from './marks.js';

interface CodeItem {
title: string;
Expand All @@ -15,6 +16,9 @@
interface Props {
items: CodeItem[];
overflow?: 'scroll' | 'wrap';
/** Render `[text]` in the code as a highlighted range, without the
brackets. Marked code renders as plain text, without Shiki. */
marks?: boolean;
footer?: Snippet<[number]>;
headerActions?: Snippet;
}
Expand All @@ -24,7 +28,7 @@
html: string;
}

let { items, overflow = 'scroll', footer, headerActions }: Props = $props();
let { items, overflow = 'scroll', marks = false, footer, headerActions }: Props = $props();

let activeTab = $state(0);
let copied = $state(false);
Expand All @@ -36,6 +40,7 @@
}

$effect(() => {
if (marks) return;
const index = activeTab;
const item = items[index];
if (!item) return;
Expand All @@ -59,7 +64,7 @@
async function copyToClipboard() {
const code = items[activeTab].code.trim();
try {
await navigator.clipboard.writeText(code);
await navigator.clipboard.writeText(marks ? stripMarks(code) : code);
copied = true;
setTimeout(() => {
copied = false;
Expand Down Expand Up @@ -136,7 +141,17 @@
<div
class="code-panel"
class:hidden={index !== activeTab}>
{#if rendered?.key === highlightKey(code, item.language)}
{#if marks}
{@const indents = wrapIndents(stripMarks(code), item.language)}
<pre><code
>{#each code.split('\n') as line, i (i)}{i > 0 ? '\n' : ''}<span
class="line"
style:--indent={indents[i]}
>{#each markSegments(line) as segment, j (j)}{#if segment.marked}<mark
>{segment.text}</mark
>{:else}{segment.text}{/if}{/each}</span
>{/each}</code></pre>
{:else if rendered?.key === highlightKey(code, item.language)}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- highlighted code from Shiki -->
{@html rendered.html}
{:else}
Expand Down Expand Up @@ -272,6 +287,14 @@
border-radius: 0;
}

/* A marked range, from `marks` mode. */
.code-panel mark {
background: var(--theme-success-bg);
color: var(--theme-success-text);
padding: 0.0625rem 0.125rem;
margin: 0 -0.125rem;
}

/* Wrap at spaces, and break a word only when it can't fit on a line of its
own. Each line hangs its continuation at --indent, set per line from
wrapIndents. */
Expand Down
37 changes: 37 additions & 0 deletions src/lib/client/ui/markdown/code/marks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Marked ranges in plain-text code, for CodeBlock's `marks` mode. A range is
// written as `[text]` in the source. The page renders it highlighted without
// the brackets; the Markdown mirror ships the brackets as they are.

export interface Segment {
text: string;
marked: boolean;
}

/** Splits a line into plain and marked segments. An unclosed `[` runs to the
end of the line. Empty segments are dropped. */
export function markSegments(line: string): Segment[] {
const segments: Segment[] = [];
let marked = false;
let start = 0;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char !== (marked ? ']' : '[')) continue;
if (i > start) segments.push({ text: line.slice(start, i), marked });
marked = !marked;
start = i + 1;
}
if (start < line.length) segments.push({ text: line.slice(start), marked });
return segments;
}

/** The code with its markers removed, for copying and measuring. */
export function stripMarks(code: string): string {
return code
.split('\n')
.map((line) =>
markSegments(line)
.map((segment) => segment.text)
.join('')
)
.join('\n');
}
Loading
Loading