@fikar-ai/design 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1379 -0
- package/brand/apps/account.svg +4 -0
- package/brand/apps/chat.svg +3 -0
- package/brand/apps/cost-calculator.svg +12 -0
- package/brand/apps/crs.svg +9 -0
- package/brand/apps/dictionary.svg +5 -0
- package/brand/apps/platform.svg +11 -0
- package/brand/apps/super-admin.svg +9 -0
- package/brand/favicon/apple-touch-icon.png +0 -0
- package/brand/favicon/favicon-16x16.png +0 -0
- package/brand/favicon/favicon-32x32.png +0 -0
- package/brand/favicon/favicon-48x48.png +0 -0
- package/brand/favicon/favicon.ico +0 -0
- package/brand/favicon/favicon.svg +11 -0
- package/brand/favicon/icon-192.png +0 -0
- package/brand/favicon/icon-512.png +0 -0
- package/brand/favicon/manifest.webmanifest +8 -0
- package/brand/fikar-loader.svg +10 -0
- package/brand/fikar-logo-mono.svg +6 -0
- package/brand/fikar-logo-reversed.svg +9 -0
- package/brand/fikar-logo.svg +15 -0
- package/components.css +577 -0
- package/fonts/Charter-license.txt +3 -0
- package/fonts/charter_bold.woff2 +0 -0
- package/fonts/charter_bold_italic.woff2 +0 -0
- package/fonts/charter_italic.woff2 +0 -0
- package/fonts/charter_regular.woff2 +0 -0
- package/fonts.css +37 -0
- package/logo.css +24 -0
- package/package.json +68 -0
- package/preset.d.ts +5 -0
- package/preset.js +67 -0
- package/recipes/brand-loader.html +4 -0
- package/recipes/detail-panel.html +35 -0
- package/recipes/empty-state.html +6 -0
- package/recipes/entity-badge.html +6 -0
- package/recipes/filter-chip.html +5 -0
- package/recipes/launcher.html +24 -0
- package/recipes/list-row.html +21 -0
- package/recipes/search-results.html +43 -0
- package/recipes/search.html +18 -0
- package/recipes/segmented.html +7 -0
- package/recipes/shell-no-sidebar.html +60 -0
- package/recipes/shell.html +89 -0
- package/recipes/status-badge.html +10 -0
- package/recipes/theme-menu.html +21 -0
- package/recipes/time.html +9 -0
- package/recipes/user-menu.html +21 -0
- package/shell.js +324 -0
- package/templates/jinja/launcher.html +45 -0
- package/templates/jinja/shell.html +97 -0
- package/templates/jinja/theme-menu.html +27 -0
- package/templates/jinja/user-menu.html +55 -0
- package/tokens.css +122 -0
package/README.md
ADDED
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
# @fikar-ai/design
|
|
2
|
+
|
|
3
|
+
The Fikar **Ink** design language as one importable package: CSS design tokens + a Tailwind preset.
|
|
4
|
+
Single source of truth for every Fikar surface, so the look is consistent by construction, not by copy-paste.
|
|
5
|
+
|
|
6
|
+
Mirrors `docs/design-language.md`. Full rollout plan: `docs/design-package-plan.md`.
|
|
7
|
+
|
|
8
|
+
## Package name (since 1.5.0)
|
|
9
|
+
|
|
10
|
+
The packages are `@fikar-ai/design` and `@fikar-ai/design-react`, published from the npm organization
|
|
11
|
+
`fikar-ai`. Versions 0.1.0 to 1.4.0 were published as `@fikar/design` and `@fikar/design-react` under
|
|
12
|
+
the npm user `fikar`, whose credentials were lost on 2026-09-30. Those versions stay on npm unchanged
|
|
13
|
+
and are not deprecated, because nobody can sign in to do it. A consumer moves by replacing the scope
|
|
14
|
+
in package.json (or in `DESIGN_VERSION` and the package name in its `fetch-design.sh`) and in every
|
|
15
|
+
import; nothing inside the package changed with the rename.
|
|
16
|
+
|
|
17
|
+
## What's in it
|
|
18
|
+
|
|
19
|
+
| Import | What |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `@fikar-ai/design/tokens.css` | The `--fk-*` ramp + shadcn semantic roles (`:root` light + `.dark`). Plain CSS — works anywhere. |
|
|
22
|
+
| `@fikar-ai/design/components.css` | The Ink component layer: badges/pills, brand loader + splash, empty state, buttons/inputs/card, search box and results, filter chips, entity badges, segmented control, detail panel, list rows, status badges, relative time, `.fk-table`, app-shell + metrics classes. Requires `tokens.css`. |
|
|
23
|
+
| `@fikar-ai/design/preset` | Tailwind preset (colors, fonts, radius, keyframes). Pure data, no plugins. |
|
|
24
|
+
| `@fikar-ai/design/brand/fikar-loader.svg` | The loader's inline-SVG markup (`.fkl-*` classes; see the loader recipe below). |
|
|
25
|
+
| `@fikar-ai/design/brand/*` | Fikar logomark SVGs: `fikar-logo.svg` (light), `fikar-logo-reversed.svg` (dark), `fikar-logo-mono.svg` (`currentColor`, small in-app/print). |
|
|
26
|
+
| `@fikar-ai/design/brand/favicon/*` | Favicon / app-icon set: `favicon.ico`, `favicon.svg`, `favicon-16/32/48.png`, `apple-touch-icon.png`, `icon-192/512.png`. |
|
|
27
|
+
| `@fikar-ai/design/logo.css` | Theme-aware swap classes (`fk-logo--light` / `fk-logo--dark`) for non-Tailwind consumers. Inside the app shell's `.brand`, use `.logo-light` / `.logo-dark` from `components.css` instead (see "Logo in the shell brand"). |
|
|
28
|
+
| `@fikar-ai/design/fonts.css` | Self-hosted **Charter** (serif) `@font-face` (4 styles, woff2). Enables the `serif` family. |
|
|
29
|
+
|
|
30
|
+
### Type roles
|
|
31
|
+
|
|
32
|
+
| Role | Family | Use |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `font-sans` | **Inter** (Google Fonts, in `index.html`) | UI chrome — buttons, labels, tables, controls. The default. |
|
|
35
|
+
| `font-serif` | **Charter** (self-hosted, `fonts.css`) | Headings + body prose / reading surfaces. |
|
|
36
|
+
| `font-mono` | **JetBrains Mono** (Google Fonts) | Numerals, IDs, code, numeric tables. |
|
|
37
|
+
|
|
38
|
+
To use Charter, `@import '@fikar-ai/design/fonts.css'` in your global stylesheet, then apply `font-serif`
|
|
39
|
+
(e.g. to headings and prose). Charter is bundled (Bitstream, redistributed by Butterick — free, no
|
|
40
|
+
CDN). License: `fonts/Charter-license.txt`; *BITSTREAM CHARTER is a registered trademark of Bitstream Inc.*
|
|
41
|
+
|
|
42
|
+
### Logo
|
|
43
|
+
|
|
44
|
+
The mark is logomark-only (no wordmark) — pair it with app text in a lockup.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
// Tailwind app — theme swap via dark: variants, no extra CSS
|
|
48
|
+
import logoLight from '@fikar-ai/design/brand/fikar-logo.svg';
|
|
49
|
+
import logoDark from '@fikar-ai/design/brand/fikar-logo-reversed.svg';
|
|
50
|
+
|
|
51
|
+
<img src={logoLight} alt="Fikar" className="block h-5 w-auto dark:hidden" />
|
|
52
|
+
<img src={logoDark} alt="" aria-hidden className="hidden h-5 w-auto dark:block" />
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Non-Tailwind (e.g. Docusaurus): `import '@fikar-ai/design/logo.css'` and use the `fk-logo--light` / `fk-logo--dark` classes. For a single-color mark that follows text color, use `fikar-logo-mono.svg` (inherits `currentColor`).
|
|
56
|
+
|
|
57
|
+
### Favicon
|
|
58
|
+
|
|
59
|
+
Favicons must sit at the web root, so they can't be imported from `node_modules` at runtime. The
|
|
60
|
+
package is the **canonical source** and each app copies the set into its web root when it builds
|
|
61
|
+
(Vite apps) or when it vendors the package (the account console and the crs explorer). Nobody
|
|
62
|
+
copies files by hand. The set in `brand/favicon/` is `favicon.ico` (16, 32 and 48 pixels),
|
|
63
|
+
`favicon.svg`, `apple-touch-icon.png` (180 by 180, padded, on a solid background), `icon-192.png`,
|
|
64
|
+
`icon-512.png` and `manifest.webmanifest`, which names the two icon files. `favicon-16x16.png`,
|
|
65
|
+
`favicon-32x32.png` and `favicon-48x48.png` ship as well but no tag links them.
|
|
66
|
+
|
|
67
|
+
Four tags in `index.html` (or the base template):
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
71
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
72
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
73
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`sizes="32x32"` on the `.ico` link is what makes Chrome pick the SVG over the `.ico`. With
|
|
77
|
+
`sizes="any"` it takes the `.ico`.
|
|
78
|
+
|
|
79
|
+
Vite apps copy the set from `node_modules` into `public/` before every dev run and build. Add two
|
|
80
|
+
scripts to `package.json` and one line per copied file to `.gitignore`:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
"sync:favicon": "node -e \"require('fs').cpSync('node_modules/@fikar-ai/design/brand/favicon','public',{recursive:true})\"",
|
|
84
|
+
"predev": "npm run sync:favicon",
|
|
85
|
+
"prebuild": "npm run sync:favicon"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```gitignore
|
|
89
|
+
# copied from @fikar-ai/design by sync:favicon
|
|
90
|
+
/public/favicon.ico
|
|
91
|
+
/public/favicon.svg
|
|
92
|
+
/public/favicon-16x16.png
|
|
93
|
+
/public/favicon-32x32.png
|
|
94
|
+
/public/favicon-48x48.png
|
|
95
|
+
/public/apple-touch-icon.png
|
|
96
|
+
/public/icon-192.png
|
|
97
|
+
/public/icon-512.png
|
|
98
|
+
/public/manifest.webmanifest
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The account console and the crs explorer vendor the package instead of installing it. Their
|
|
102
|
+
vendoring step copies `brand/favicon/*` into the directory their server serves at `/`, next to the
|
|
103
|
+
rest of the vendored files, and records the package version it copied from.
|
|
104
|
+
|
|
105
|
+
The guard: add a check to `scripts/check-shared-ui.mjs` that every file in
|
|
106
|
+
`node_modules/@fikar-ai/design/brand/favicon/` which also exists in the web root (`public/` for Vite
|
|
107
|
+
apps) is byte for byte equal to the package file. A difference is a violation naming the file. A
|
|
108
|
+
missing file is not a violation, because the copy runs at build time and the directory is ignored
|
|
109
|
+
by git.
|
|
110
|
+
|
|
111
|
+
#### Added in 1.4.0
|
|
112
|
+
|
|
113
|
+
- `brand/favicon/manifest.webmanifest`, with `name` and `short_name` "FIKAR" and the `icon-192.png` and `icon-512.png` entries. It sets no `theme_color`, `background_color`, `start_url` or `display`. No new icon file, and no maskable icon.
|
|
114
|
+
- The snippet above replaces the five tag one. The `.ico` link is now `sizes="32x32"` (was `any`), the two PNG links are gone, and the manifest link is new. An app on the old snippet keeps working, and moves when it adopts the copy step.
|
|
115
|
+
|
|
116
|
+
## Component layer (`components.css`, since 0.2.0)
|
|
117
|
+
|
|
118
|
+
Plain CSS classes matching `docs/mockups/fikar.css` names: `.badge .b-*`, `.pill .p-*`,
|
|
119
|
+
`.fikar-loader`/`.fikar-splash`, `.empty`, `.btn*`/`.iconbtn`, `.inp`/`.field`/`.hint`,
|
|
120
|
+
`.card`/`.panel-head`/`.count`, `.avatar`/`.av`, `.user-menu-*` (the user menu), `.search*` (the search box and its results, see "Search"), `.p-toggle`, `.segmented*`, `.b-person`/`.b-organization`/`.b-event`/`.b-geo`, `.detail-*` (the detail panel), `.list-row*`/`.day-sep`, `.b-success`/`.b-warning`/`.b-destructive`/`.b-muted` (status badges), `.fk-time`, `.switch`, `.metrics`/`.metric`/`.delta`,
|
|
121
|
+
app shell (`.shell`/`.sidebar`/`.topbar`/`.appbar`/`.nav-item`…), and **`.fk-table`**
|
|
122
|
+
(tables are class-scoped so the package never restyles a host app's tables).
|
|
123
|
+
|
|
124
|
+
Rules:
|
|
125
|
+
- `@import` it **after** `tokens.css` and **before** your `@tailwind` directives. It is
|
|
126
|
+
deliberately un-layered (Tailwind v3 intercepts `@layer components` in consumer builds);
|
|
127
|
+
import order is what lets Tailwind utilities win ties.
|
|
128
|
+
- Status colors ride the role vars (`--warning`, `--destructive`…), so dark mode is automatic.
|
|
129
|
+
- `--error` is not a role; the layer uses `--destructive`.
|
|
130
|
+
|
|
131
|
+
### Brand loader recipe
|
|
132
|
+
|
|
133
|
+
The loader animates **inline SVG internals**, so an `<img>` won't work. Markup: `recipes/brand-loader.html`
|
|
134
|
+
(`@fikar-ai/design/recipes/brand-loader.html`). Paste the `<svg>` from `brand/fikar-loader.svg` into a small
|
|
135
|
+
local wrapper once; that wrapper is the one sanctioned copy-paste. Size modifiers are `xs`, `sm` and `lg`.
|
|
136
|
+
|
|
137
|
+
Inside a `.btn` it inherits the button's text color. Honors `prefers-reduced-motion`
|
|
138
|
+
(freezes to the fully drawn mark).
|
|
139
|
+
|
|
140
|
+
### App shell recipe (responsive since 0.4.0, desktop collapse since 0.5.0, script and macros since 0.6.0, optional pieces since 0.7.0, no-sidebar mode and more in 0.8.0)
|
|
141
|
+
|
|
142
|
+
Markup: `recipes/shell.html` (`@fikar-ai/design/recipes/shell.html`). Consumers write no media queries.
|
|
143
|
+
`recipes/demo.html` in the repo is a page for checking the recipe by hand in a browser; it is not published.
|
|
144
|
+
|
|
145
|
+
Rules, in force on every surface:
|
|
146
|
+
|
|
147
|
+
- Below 1024px the sidebar is an off-canvas drawer and a sticky top bar carries the menu button,
|
|
148
|
+
the brand and the account cluster. The collapsed state has no effect here.
|
|
149
|
+
- From 1024px the sidebar shows open at 248px. It can collapse to an icon rail at 72px.
|
|
150
|
+
- The account cluster (launcher, theme control, avatar) sits at the top right of the top bar on every surface.
|
|
151
|
+
- The collapse control (`.shell-collapse`) is the last child of the sidebar, so it sits at the bottom in
|
|
152
|
+
the same position open or collapsed. It is hidden below 1024px.
|
|
153
|
+
- The choice is stored in `localStorage` under `fikar.shell.collapsed`: the value `"1"` means collapsed,
|
|
154
|
+
`"0"` means open, and an absent key is read as open. Since 0.8.0 both `shell.js` and `@fikar-ai/design-react` write `"0"` on
|
|
155
|
+
expand; an older surface that removes the key is still read correctly. Storage is per origin, so the choice does not follow a person from
|
|
156
|
+
one app to another.
|
|
157
|
+
|
|
158
|
+
Collapsed state: the host puts `.is-collapsed` on `.shell` (the same way `.nav-open` drives the drawer).
|
|
159
|
+
In the rail each nav item shows its icon only, the brand shows the mark only, and the side foot shows the
|
|
160
|
+
avatar only. Every nav item label goes in `<span class="nav-label">`. It is clipped, not removed, so it stays
|
|
161
|
+
in the accessibility tree, and it is the text for the tooltip: React apps use Radix, plain HTML uses the
|
|
162
|
+
`title` attribute. The package styles the state and does not implement tooltips.
|
|
163
|
+
|
|
164
|
+
Upgrading from 0.4.0 needs no markup change. Everything new is opt-in through `.is-collapsed`, `.shell-collapse`
|
|
165
|
+
and `.nav-label`, so unchanged markup renders as before.
|
|
166
|
+
|
|
167
|
+
Toggle contract. Plain HTML and server-rendered hosts get it from `shell.js` (below); React hosts implement it themselves:
|
|
168
|
+
|
|
169
|
+
- Menu button: adds `.nav-open` to `.shell` and sets `aria-expanded="true"`. Escape, the scrim and the close
|
|
170
|
+
button remove it and return focus to the menu button.
|
|
171
|
+
- Collapse control: toggles `.is-collapsed` and writes `"1"` (collapsed) or `"0"` (open) to `fikar.shell.collapsed`. The fixture ships in the
|
|
172
|
+
open state (`aria-pressed="false"`, `title="Collapse sidebar"`). On collapse the host sets `aria-pressed="true"`
|
|
173
|
+
and changes the `title` to "Expand sidebar"; on expand it reverses both. Read the key once on load, before first paint where the host can.
|
|
174
|
+
|
|
175
|
+
#### Optional pieces (since 0.7.0)
|
|
176
|
+
|
|
177
|
+
None of these is in `recipes/shell.html`, and none changes markup that does not use it. Their fixture, with all of
|
|
178
|
+
them present, is `test/fixtures/shell-extras.html` in the repo (not published).
|
|
179
|
+
|
|
180
|
+
- Tooltip: `.fk-tooltip` styles a tooltip surface from tokens, light and dark. React puts it on the Radix content in
|
|
181
|
+
the rail. Plain HTML keeps using the `title` attribute.
|
|
182
|
+
- Top bar start slot: an optional `<div class="shell-topbar-start">` between the top bar `.brand` and `.cluster`. It
|
|
183
|
+
takes the free width (`flex: 1; min-width: 0`) and the cluster stays at the right edge. Put a search box or a page
|
|
184
|
+
title in it. Omit the element when there is nothing to show.
|
|
185
|
+
- Nav heading: `<div class="nav-heading">Workspace</div>` between groups of nav items. Small, uppercase, muted. In the
|
|
186
|
+
rail it is clipped, not removed, like `.nav-label`.
|
|
187
|
+
- Nav badge: `<span class="nav-badge">New</span>` as the last child of a `.nav-item`, pushed to the right. Hidden in the rail.
|
|
188
|
+
- Scrolling nav: add `is-scroll` to the nav (`<nav class="side is-scroll">`) when it can hold a long list. The nav then
|
|
189
|
+
scrolls inside the sidebar, and the side foot and the collapse control stay in view. It relies on `:has()` to bound
|
|
190
|
+
the sidebar height.
|
|
191
|
+
The nav reserves a scrollbar gutter (`scrollbar-gutter: stable`) and keeps 4px between items and a classic scrollbar, so
|
|
192
|
+
items have one width whether or not the list scrolls. With overlay scrollbars nothing is reserved and items do not move.
|
|
193
|
+
The 72px rail reserves no gutter.
|
|
194
|
+
|
|
195
|
+
#### Added in 0.8.0
|
|
196
|
+
|
|
197
|
+
Everything here is opt-in unless the "Upgrading from 0.7" list below says otherwise.
|
|
198
|
+
|
|
199
|
+
##### No-sidebar mode
|
|
200
|
+
|
|
201
|
+
For an app that has a top bar and no sidebar. Markup: `recipes/shell-no-sidebar.html`. The root is
|
|
202
|
+
`div.shell.with-topbar.no-sidebar#shell`, holding `header.shell-topbar` (`.brand`, the optional
|
|
203
|
+
`.shell-topbar-start`, `.cluster`) and `main.main > div.content`. It has no `aside`, no scrim, no `.shell-menu` and
|
|
204
|
+
no `.shell-collapse`, and it must not get any.
|
|
205
|
+
|
|
206
|
+
- One column. The top bar `.brand` shows at every width (with a sidebar it is hidden from 1024px, because the
|
|
207
|
+
sidebar holds the brand). The bar is at least 56px tall and sticky, and the cluster sits at the right edge.
|
|
208
|
+
- `.shell-topbar-start` sits between the brand and the cluster. In this mode it shrinks below its content, never
|
|
209
|
+
wraps and scrolls sideways inside itself with no visible scrollbar, so a pill and a row of four links cannot make the
|
|
210
|
+
bar taller or the page wider at 390px. Every link stays reachable by touch, trackpad and Tab, and a link that takes
|
|
211
|
+
focus scrolls into view. The first items are what shows without scrolling, so put the important ones first.
|
|
212
|
+
- Height behaves like the sidebar shell: from 1024px the shell is one viewport tall and `.content` scrolls inside it,
|
|
213
|
+
below that the page scrolls. Add `is-fill` (below) for a page that scrolls inside itself at every width.
|
|
214
|
+
- `shell.js` skips these shells. It reads and writes no storage for them, binds nothing, and a stored `"1"` cannot
|
|
215
|
+
put `.is-collapsed` on one.
|
|
216
|
+
- Jinja: `topbar_shell(brand, cluster, start='', fill=false)`, a separate macro so neither signature carries
|
|
217
|
+
arguments that do nothing. React: `<AppShell sidebar={false} ...>`.
|
|
218
|
+
|
|
219
|
+
##### Macro and component options
|
|
220
|
+
|
|
221
|
+
- `shell(..., nav_label='Main')` sets the sidebar nav's `aria-label`. React already had `navLabel`.
|
|
222
|
+
- A nav item in the macro takes an optional `class`, appended after `nav-item` and `active` and escaped like the other
|
|
223
|
+
text. React has no `className` passthrough (KAN-311); `NavItem` takes `variant="back"` instead, which adds the same
|
|
224
|
+
`nav-item-back` class.
|
|
225
|
+
- `.nav-item-back` is the link that leads out of this surface back to where the person came from. It is smaller and
|
|
226
|
+
lighter than the items below it, with a rule under it. Put it first in the nav. In the macro, pass
|
|
227
|
+
`class: 'nav-item-back'`.
|
|
228
|
+
- `foot` is optional. When it is empty (or only whitespace) the macro renders no `.side-foot`, and `AppShell` renders
|
|
229
|
+
none when `footer` is not given, so there is no bare divider above the collapse control. The collapse control stays at
|
|
230
|
+
the bottom without it, provided it follows the nav or the foot directly. A host block placed between the nav and the
|
|
231
|
+
control, with no foot, leaves the control floating below the nav.
|
|
232
|
+
|
|
233
|
+
##### Logo in the shell brand
|
|
234
|
+
|
|
235
|
+
A brand can hold both logo variants and show one. Put both in `.brand`:
|
|
236
|
+
|
|
237
|
+
```html
|
|
238
|
+
<img class="logo-light" src="/brand/fikar-logo.svg" alt="">
|
|
239
|
+
<img class="logo-dark" src="/brand/fikar-logo-reversed.svg" alt="">
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The classes work on `<img>` and on an inline `<svg>` (an svg gets the same 18px default height an img does). Dark means
|
|
243
|
+
`.dark` or `[data-theme='dark']` on any ancestor, the same two switches `tokens.css` and `logo.css` use. The tokens have
|
|
244
|
+
no `prefers-color-scheme` rule, so there is no system mode to follow here. A host that offers "system" resolves it to
|
|
245
|
+
one of those two switches, and one that resolves it in CSS only adds its own
|
|
246
|
+
`@media (prefers-color-scheme: dark)` rule for `[data-theme='system'] .brand .logo-light` and `.logo-dark`. Give both
|
|
247
|
+
variants the same `alt`, or `alt=""` beside the wordmark; only one is displayed at a time. The selectors have two
|
|
248
|
+
classes, so they beat `.brand img` and Tailwind's `hidden`, and the old workaround of wrapper spans around each
|
|
249
|
+
variant is no longer needed. In the Jinja macros the host passes the brand in, so it writes these two elements. React
|
|
250
|
+
apps use `BrandLogo` (see the React section).
|
|
251
|
+
|
|
252
|
+
##### Rail utilities
|
|
253
|
+
|
|
254
|
+
- `.rail-hidden` hides content in the collapsed rail (1024px and wider) and is fully visible open and in the drawer.
|
|
255
|
+
It is clipped like `.nav-label` and also `visibility: hidden`, so it takes no space, no sight and no focus. The
|
|
256
|
+
trade-off is that it also leaves the accessibility tree in the rail. That is deliberate. Plain clipping would keep
|
|
257
|
+
every link and button inside it reachable by Tab, which puts focus on something nobody can see, and the block this
|
|
258
|
+
exists for is a list of links. Text that a screen reader must still hear in the rail belongs in `.nav-label`.
|
|
259
|
+
- `.rail-only` shows content only in the collapsed rail at 1024px and wider, and is `display: none` otherwise. It
|
|
260
|
+
hides itself outside the rail and sets no display value when collapsed, so it keeps the display its own
|
|
261
|
+
class gives it. It sets no layout, so the host lays it out for the 72px rail (the rail centres its own
|
|
262
|
+
icons and does not centre a bare `.rail-only` block; put `text-align: center` or `justify-content: center` on it).
|
|
263
|
+
|
|
264
|
+
##### Fill mode
|
|
265
|
+
|
|
266
|
+
`is-fill` on `.shell` is for a page that scrolls inside itself, such as a conversation with a composer docked at the
|
|
267
|
+
bottom. The shell is exactly one viewport tall at every width (`100vh`, then `100dvh`), `.main` does not overflow and
|
|
268
|
+
`.content` is a flex column with `min-height: 0` and no padding. The document does not scroll; whatever inside
|
|
269
|
+
`.content` has `overflow-y: auto` does. The drawer works as usual. Use it with the shell that has a top bar
|
|
270
|
+
(`.with-topbar`), which every recipe does. Macros: `fill=true` on `shell()` and `topbar_shell()`. React: `fill` on
|
|
271
|
+
`AppShell`, with or without a sidebar.
|
|
272
|
+
|
|
273
|
+
##### Upgrading from 0.7
|
|
274
|
+
|
|
275
|
+
Nothing has to change to upgrade, and markup that uses none of the new classes renders as before except for the
|
|
276
|
+
changes below. Each one is deliberate.
|
|
277
|
+
|
|
278
|
+
1. Footer rule. `.side-foot .meta span` and `.meta b` are now `.side-foot .meta > span` and `.meta > b`. A third element
|
|
279
|
+
in the identity block, such as a plan pill inside a wrapper span, no longer inherits the muted 11px text. Markup
|
|
280
|
+
that puts `<b>` and `<span>` directly in `.meta`, as the recipe does, looks the same. Remove any forced colour
|
|
281
|
+
(Tailwind's important modifier, or a higher-specificity override) that you added to escape the old rule.
|
|
282
|
+
2. Grid children shrink. `.shell-topbar` and `.main` get `min-width: 0`, and `.shell-topbar` gets `overflow-x: clip`, so
|
|
283
|
+
a long nowrap title in the top bar no longer widens the page past the viewport. It changes layout only where
|
|
284
|
+
content was wider than the column, which used to cause horizontal scroll. Menus that open downward out of the bar are
|
|
285
|
+
not cut off, because only the x axis is clipped.
|
|
286
|
+
3. Closed drawer takes no focus. Below 1024px the closed sidebar is `visibility: hidden`, so Tab no longer lands on
|
|
287
|
+
its invisible close button and links. The slide-out animation is kept. It shows as a change only in focus order.
|
|
288
|
+
4. Empty footer. If you passed no footer (React) or an empty `foot` (macro), an empty `.side-foot` used to render a bare
|
|
289
|
+
divider. It no longer renders.
|
|
290
|
+
|
|
291
|
+
The logo classes change no existing rule. The 0.4.0 `.brand img` rule is untouched, and `.brand .logo-dark` outranks
|
|
292
|
+
it by specificity.
|
|
293
|
+
|
|
294
|
+
What each kind of consumer can delete when it upgrades:
|
|
295
|
+
|
|
296
|
+
- Wrapper spans around the two logo variants (`<span class="dark:hidden">`): use `BrandLogo` or the two classes.
|
|
297
|
+
- Forced colours in the footer, such as a plan pill with an important text colour: remove them.
|
|
298
|
+
- Local hide-in-rail rules (`.is-collapsed .something { display: none }`): use `.rail-hidden`, or `.rail-only` for the
|
|
299
|
+
opposite.
|
|
300
|
+
- Local fill patches (`height: 100dvh` on the shell, `overflow: hidden` on `.main`, a padless flex `.content`): pass
|
|
301
|
+
`fill` or add `is-fill`.
|
|
302
|
+
- A "Back to app" link written as an ordinary first nav item, and a local rule that makes it quieter: use
|
|
303
|
+
`variant="back"` or `class: 'nav-item-back'`.
|
|
304
|
+
- A hand-written top bar for an app with no sidebar: use `sidebar={false}` or `topbar_shell`.
|
|
305
|
+
|
|
306
|
+
#### Fixed in 1.0.1
|
|
307
|
+
|
|
308
|
+
The scrolling nav (`nav.side.is-scroll`, see "Optional pieces") now reserves a scrollbar gutter and keeps 4px between
|
|
309
|
+
its items and a classic scrollbar. Before, items touched the scrollbar and changed width when the list began to
|
|
310
|
+
scroll. Under overlay scrollbars and in the 72px rail nothing moves. Nothing has to change to upgrade.
|
|
311
|
+
|
|
312
|
+
#### shell.js (since 0.6.0)
|
|
313
|
+
|
|
314
|
+
One dependency-free classic script that implements the toggle contract above for plain HTML and server-rendered
|
|
315
|
+
surfaces. It binds by the existing class names (`.shell`, `.shell-menu`, `.shell-close`, `.shell-scrim`,
|
|
316
|
+
`.shell-collapse`), so the fixture markup needs no attributes for it.
|
|
317
|
+
|
|
318
|
+
```html
|
|
319
|
+
<script src="/static/fikar/shell.js"></script>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Copy or serve `@fikar-ai/design/shell.js` from your static files. It needs no bundler and no module loader, and it
|
|
323
|
+
can also be inlined into a page. Every `.shell` and every `[data-fk-menu]` in the document is initialised on `DOMContentLoaded`, or at once
|
|
324
|
+
if the document is already parsed. `package.json` has `"type": "module"`, so a bundler that imports the file
|
|
325
|
+
should import it for its side effect only; it has no exports.
|
|
326
|
+
|
|
327
|
+
To initialise a shell your code inserts later, call `window.FikarShell.init(root)`. `root` is a `.shell` element or
|
|
328
|
+
any container holding one, and it defaults to `document`. A menu root works as `root` too. Calling it again on the same element does nothing, so
|
|
329
|
+
it never double-binds.
|
|
330
|
+
|
|
331
|
+
Behaviour beyond the contract:
|
|
332
|
+
|
|
333
|
+
- The collapse toggle does nothing below 1024px, and opening the drawer never writes the collapse key. Collapsing writes
|
|
334
|
+
`"1"` and expanding writes `"0"`.
|
|
335
|
+
- A `.shell.no-sidebar` is skipped entirely (no binding, no key read, no `.is-collapsed`).
|
|
336
|
+
- If the viewport grows to 1024px or wider while the drawer is open, the drawer closes so the scrim does not
|
|
337
|
+
stay over the page. Focus is left where it is in that case.
|
|
338
|
+
- If `localStorage` throws (private mode, blocked site data), the shell still works for the session and the
|
|
339
|
+
choice is not remembered.
|
|
340
|
+
- The scrim carries `hidden` while the drawer is closed, and the script removes it while the drawer is open.
|
|
341
|
+
- Tooltips in the rail use the `title` attribute already in the fixture. The script builds no tooltip.
|
|
342
|
+
- A search box opts in with `data-fk-search` (since 1.3.0). See "Search". A page without one, and without a `.search-clear`, gets no extra listeners.
|
|
343
|
+
- `FikarShell.formatRelative` and `FikarShell.formatExact` give the shared wording for a moment (since 1.3.0). They are functions to call and bind nothing. See "Relative time".
|
|
344
|
+
- Menus opt in with `data-fk-menu` (since 1.1.0). See "User menu". A page without one gets no extra listeners. Since 1.2.0 a menu may hold `role="menuitemradio"` rows, which take the arrow keys and close the menu like any row. Choosing one marks it and sends `fk-theme-change` (see "Theme menu").
|
|
345
|
+
|
|
346
|
+
Zero-flash snippet. The script restores the collapsed state as soon as it runs, but a script at the end of the
|
|
347
|
+
page can run after the first paint, which shows the sidebar open and then jumping to the rail. To avoid that,
|
|
348
|
+
put this directly after the opening `.shell` tag:
|
|
349
|
+
|
|
350
|
+
```html
|
|
351
|
+
<script>try{if(localStorage.getItem("fikar.shell.collapsed")==="1")document.currentScript.parentNode.classList.add("is-collapsed")}catch(e){}</script>
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
It only sets the class. `shell.js` still sets `aria-pressed` and `title` when it runs. Under a strict
|
|
355
|
+
Content-Security-Policy an inline script needs a nonce (`<script nonce="...">`) or a hash in `script-src`.
|
|
356
|
+
Without one the browser blocks the snippet, `shell.js` still works, and the page may show a brief jump from open
|
|
357
|
+
to collapsed on load.
|
|
358
|
+
|
|
359
|
+
#### Moving a page to shell.js (since 1.2.0)
|
|
360
|
+
|
|
361
|
+
A page that already wires its launcher, theme control or account menu by itself keeps working when it takes a newer package. `launcher(apps)`
|
|
362
|
+
and `recipes/launcher.html` produce the same markup as 1.1.0, with no `data-fk-menu`, and `shell.js` does not bind a menu without it.
|
|
363
|
+
|
|
364
|
+
`shell.js` only knows the menus that carry `data-fk-menu`. Opening one of them closes the others that carry it, and a press outside closes them. A menu the page wires itself is
|
|
365
|
+
invisible to it, so it is not closed when a shared menu opens, and a page that has both can show two panels at once. A page must not mix the two. Move every menu in the cluster in one change. Version 1.1.0 allowed the mix, and the account console and the graph explorer are in it today, with the user menu on `shell.js` and the launcher wired by hand, so their move includes the launcher.
|
|
366
|
+
|
|
367
|
+
1. Delete the page's own wiring for the launcher and for the other menus in the top bar: the click handler on the trigger, the Escape and outside press handlers, and the code that returns focus.
|
|
368
|
+
2. Add `data-fk-menu` to each menu root, or render it with the macros (`launcher(apps, data_fk_menu=true)`, `theme_menu`, `user_menu`).
|
|
369
|
+
3. Load `shell.js`. If your code inserts a menu later, call `FikarShell.init(root)` on it.
|
|
370
|
+
4. Open each menu once. If one click opens the panel and it closes again at once, the page's own handler is still there and both are toggling it.
|
|
371
|
+
|
|
372
|
+
#### Jinja macros (since 0.6.0)
|
|
373
|
+
|
|
374
|
+
`templates/jinja/shell.html` and `templates/jinja/launcher.html` render the same markup as the two recipes. They
|
|
375
|
+
hold no host logic: no `url_for`, no globals. Everything comes in as arguments.
|
|
376
|
+
|
|
377
|
+
- `shell(brand, nav, foot, cluster, start='', scroll=false, nav_label='Main', fill=false)` with the main content as the `{% call %}` body. `brand` is
|
|
378
|
+
the logo and word markup, used in the sidebar and the top bar. `nav` is a list of entries. An item has `label`,
|
|
379
|
+
`href`, `icon` (svg markup) and optionally `active`, `title` (defaults to the label), `badge` (text) and `class` (extra
|
|
380
|
+
class names after `nav-item` and `active`, such as `nav-item-back`). An entry
|
|
381
|
+
with `heading: true` and a `label` renders a nav heading instead. `foot` is the optional side foot markup and renders nothing when empty. `cluster` is the
|
|
382
|
+
top bar cluster markup. `start` is optional markup for the top bar start slot and renders nothing when empty.
|
|
383
|
+
`scroll=true` makes the nav scrollable.
|
|
384
|
+
- `topbar_shell(brand, cluster, start='', fill=false)` for an app with no sidebar, with the main content as the call body. It
|
|
385
|
+
renders `recipes/shell-no-sidebar.html`.
|
|
386
|
+
- `launcher(apps, data_fk_menu=false)` with a list of items with `label`, `href`, `id` and optionally `current` (and `hint`, which is accepted and not shown). The `id` picks the glyph, see "App launcher recipe". An empty list
|
|
387
|
+
renders nothing. `data_fk_menu=true` adds `data-fk-menu` to the root, which hands the launcher to `shell.js` (since 1.2.0, see "Moving a page to shell.js").
|
|
388
|
+
- `user_menu(email, sign_out_href, name, avatar_url, account_href, items)` and `user_initials(name, email)` in `templates/jinja/user-menu.html`, since 1.1.0. See "User menu".
|
|
389
|
+
- `theme_menu(value='system')` in `templates/jinja/theme-menu.html`, since 1.2.0. See "Theme menu".
|
|
390
|
+
|
|
391
|
+
Markup arguments (`brand`, `foot`, `cluster`, each `icon`) must be safe markup when autoescape is on: `Markup(...)`
|
|
392
|
+
in Python, `|safe` or a `{% set x %}...{% endset %}` block in a template. Text fields are escaped.
|
|
393
|
+
|
|
394
|
+
A Python host loads the package directory through a prefix loader:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
from jinja2 import Environment, FileSystemLoader, PrefixLoader
|
|
398
|
+
|
|
399
|
+
env = Environment(
|
|
400
|
+
loader=PrefixLoader({"fikar": FileSystemLoader("node_modules/@fikar-ai/design/templates/jinja")}),
|
|
401
|
+
autoescape=True,
|
|
402
|
+
)
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```jinja
|
|
406
|
+
{% from "fikar/shell.html" import shell %}
|
|
407
|
+
{% from "fikar/launcher.html" import launcher %}
|
|
408
|
+
{% set cluster %}{{ launcher(apps) }}<span class="avatar" aria-hidden="true">AM</span>{% endset %}
|
|
409
|
+
{% set brand %}<img src="{{ logo_url }}" alt=""><span class="word">FIKAR</span>{% endset %}
|
|
410
|
+
{% set foot %}<span class="avatar" aria-hidden="true">AM</span>{% endset %}
|
|
411
|
+
{% call shell(brand=brand, nav=nav, foot=foot, cluster=cluster) %}
|
|
412
|
+
<h1>Home</h1>
|
|
413
|
+
{% endcall %}
|
|
414
|
+
<script src="{{ static_url('fikar/shell.js') }}"></script>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The launcher behaviour (open, Escape, outside click) is still the host's unless the launcher root carries `data-fk-menu`, which `launcher(apps, data_fk_menu=true)` writes; `shell.js` does not touch a `.launcher` without it.
|
|
418
|
+
|
|
419
|
+
### App launcher recipe (since 0.3.0)
|
|
420
|
+
|
|
421
|
+
Every FIKAR app shows the same launcher: a grid button left of the avatar that shows a grid of tiles, three to a row, one for each of the
|
|
422
|
+
apps the signed-in person may open, fetched once per session from the identity provider
|
|
423
|
+
(`GET https://auth.fikar.ai/api/auth/me/apps`, bearer token). The list is already filtered
|
|
424
|
+
and ordered server-side; render it, mark the current app, and link. The provider session
|
|
425
|
+
cookie does the SSO on click. An empty list means no launcher at all.
|
|
426
|
+
|
|
427
|
+
Markup: `recipes/launcher.html` (`@fikar-ai/design/recipes/launcher.html`).
|
|
428
|
+
|
|
429
|
+
Behaviour is the host's unless the root carries `data-fk-menu` (see "User menu"). A plain-JS host toggles `.open` on
|
|
430
|
+
`.launcher` and `aria-expanded` on the button, closes on Escape and outside click, and returns
|
|
431
|
+
focus to the button. The panel is hidden until `.launcher.open`.
|
|
432
|
+
|
|
433
|
+
Since 1.2.0 the launcher can also use the shared behaviour, the same way as the user menu.
|
|
434
|
+
|
|
435
|
+
- Plain HTML and Jinja: add `data-fk-menu` to the root, or call `launcher(apps, data_fk_menu=true)`. `shell.js` then opens it on a click or Enter, closes it on Escape (focus returns to the button), on a press outside and on choosing a link, and moves between the links with the arrow keys, Home and End. The recipe file itself has no attribute, because it is the markup a page that wires the launcher itself already copies. The shell recipes carry it.
|
|
436
|
+
- The current app has `.current` and `aria-current="page"`, and its tile is the primary colour. The apps come from the page, and nothing is fetched.
|
|
437
|
+
- Each tile is a 36px `.launcher-tile` with a 20px glyph over the app name, and the name may wrap to two lines. The hint is not shown. The panel carries `.launcher-apps`, which is what makes it a grid; every grid rule is scoped to that class, so the search results, the user menu and the theme menu, which reuse `.launcher-panel` and `.launcher-item`, keep their look, and a host whose copy of the markup has no `.launcher-apps` keeps the old list.
|
|
438
|
+
- The package owns the glyphs, keyed by app id: `chat`, `platform`, `crs`, `dictionary`, `cost-calculator`, `super-admin` and `account`. They are lucide 0.469 SVG files in `brand/apps/<id>.svg` (24px frame, 1.5 stroke, `currentColor`). An id the package does not know shows the first letter of the app name in the same tile. The Jinja `launcher` macro and the React component need the app `id` for this; an item without one shows the letter.
|
|
439
|
+
- `react/src/app-icons.ts` and the icon map in `templates/jinja/launcher.html` are generated from those files by `node scripts/build-app-icons.mjs`. Change an SVG, run the script and commit the result; a test fails when they differ.
|
|
440
|
+
- React: `AppLauncher` renders the recipe and owns its behaviour, so it does not render `data-fk-menu`.
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
import { AppLauncher } from '@fikar-ai/design-react';
|
|
444
|
+
|
|
445
|
+
<AppLauncher
|
|
446
|
+
apps={[
|
|
447
|
+
{ id: 'chat', name: 'Chat', url: 'https://chat.fikar.ai', hint: "Ask across your organization's memory" },
|
|
448
|
+
{ id: 'platform', name: 'Platform', url: 'https://platform.fikar.ai' },
|
|
449
|
+
]}
|
|
450
|
+
currentId="chat"
|
|
451
|
+
/>
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
| Prop | Meaning |
|
|
455
|
+
|---|---|
|
|
456
|
+
| `apps` | Required. The list the app already has, filtered and ordered. Each has `id`, `name`, `url` and an optional `hint` (accepted, not shown). With no apps the component renders nothing. |
|
|
457
|
+
| `currentId` | The `id` of the app the person is in. That row gets `.current` and `aria-current="page"`. |
|
|
458
|
+
|
|
459
|
+
The rows are plain anchors, because they leave the app. Arrow keys, Home and End move between the tiles in document order.
|
|
460
|
+
|
|
461
|
+
### User menu (since 1.1.0)
|
|
462
|
+
|
|
463
|
+
The avatar and account panel that ends the shell cluster, after the launcher and the theme control. Every surface
|
|
464
|
+
renders this one. A local copy is what let six of them drift, so the rule in "Rule for agents and people writing UI"
|
|
465
|
+
applies without exception.
|
|
466
|
+
|
|
467
|
+
Markup: `recipes/user-menu.html` (`@fikar-ai/design/recipes/user-menu.html`). It is also in the cluster of `recipes/shell.html`
|
|
468
|
+
and `recipes/shell-no-sidebar.html`. Shown here signed in with a name, no picture, one extra row and Sign out as a link.
|
|
469
|
+
|
|
470
|
+
```html
|
|
471
|
+
<div class="launcher" data-fk-menu>
|
|
472
|
+
<button class="user-menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Open account menu">
|
|
473
|
+
<span class="avatar" aria-hidden="true">AM</span>
|
|
474
|
+
</button>
|
|
475
|
+
<div class="launcher-panel user-menu-panel" role="menu">
|
|
476
|
+
<div class="user-menu-id">
|
|
477
|
+
<span class="user-menu-name">Alex Morgan</span>
|
|
478
|
+
<span class="user-menu-email">alex@example.com</span>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
481
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/account">Account settings</a>
|
|
482
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/workspace">Workspace settings</a>
|
|
483
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
484
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/logout">Sign out</a>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
The variants, all inside the same structure.
|
|
490
|
+
|
|
491
|
+
- With a picture, the initials span becomes `<img class="avatar" src="..." alt="">`. The button carries the accessible name, so the image has no alt text. The trigger always carries `data-initials`, and a picture that fails to load is replaced by the same `<span class="avatar" aria-hidden="true">` the markup has with no picture.
|
|
492
|
+
- With no name, `.user-menu-id` holds the email once, in `.user-menu-name`, and there is no `.user-menu-email`.
|
|
493
|
+
- Account settings is optional. The account console leaves it out because the row would link to the page you are on.
|
|
494
|
+
- Extra rows go after Account settings and before Sign out. Each is `<a class="launcher-item user-menu-item" role="menuitem" href="...">`.
|
|
495
|
+
- The second `.user-menu-sep` is only there when a row comes before Sign out.
|
|
496
|
+
- Sign out is a link to the host's own sign out address, so where a signed out person lands stays with each surface. A host that must run code first can use
|
|
497
|
+
`<button class="launcher-item user-menu-item" type="button" role="menuitem">Sign out</button>` instead, and wire it itself or through the React `signOut.onSelect`.
|
|
498
|
+
|
|
499
|
+
Classes. All are new in 1.1.0 and nothing existing changed, so a host that still styles `.avatar-btn`, `.menu-id` or `.menu-sep` locally renders as before.
|
|
500
|
+
|
|
501
|
+
| Class | What it is |
|
|
502
|
+
|---|---|
|
|
503
|
+
| `.launcher`, `.launcher-panel` | The root and the panel, reused. `.open` on the root shows the panel, and the panel's right edge sits on the trigger's right edge. |
|
|
504
|
+
| `.user-menu-btn` | The trigger. A real `button`, 32 by 32, round, with a focus ring. |
|
|
505
|
+
| `.avatar` inside it | The initials or picture, blue with white text as everywhere else. `.user-menu-btn .avatar` makes it 32px there. The `.avatar` class itself is still 28px. |
|
|
506
|
+
| `.user-menu-panel` | Keeps the panel within 16px of the viewport edge on a phone. |
|
|
507
|
+
| `.user-menu-id`, `.user-menu-name`, `.user-menu-email` | The identity block. Long names and emails wrap. |
|
|
508
|
+
| `.user-menu-sep` | The separator. |
|
|
509
|
+
| `.launcher-item` with `.user-menu-item` | The rows. They reuse `.launcher-item` for the look. `.user-menu-item` adds a focus ring, and the resets a `button` row needs, scoped so a `button.launcher-item` in a host is untouched. |
|
|
510
|
+
|
|
511
|
+
The rules every surface follows.
|
|
512
|
+
|
|
513
|
+
- The avatar is 32px and blue with white text when there is no picture.
|
|
514
|
+
- Initials are the first letter of the first name and of the last name, upper case. A single name gives one letter, and with no name it is the first letter of the email. Words are split on any whitespace, and non-ASCII letters work.
|
|
515
|
+
- The identity block shows the full name and the email under it, or the email alone when there is no name.
|
|
516
|
+
- The rows are Account settings if the surface has it, then the surface's own rows, then Sign out.
|
|
517
|
+
|
|
518
|
+
#### Behaviour and how a page opts in
|
|
519
|
+
|
|
520
|
+
`shell.js` opens and closes any menu that carries `data-fk-menu` on its root. The recipe and the macro put it on the user menu.
|
|
521
|
+
`FikarShell.init(root)` binds menus the same way it binds shells, and calling it again does nothing. For each menu it does this.
|
|
522
|
+
|
|
523
|
+
- A click on the trigger toggles it. Enter and Space work because the trigger is a `button`. `aria-expanded` follows the state, and `aria-haspopup="menu"` is in the markup.
|
|
524
|
+
- Escape closes it and puts focus on the trigger. A press outside closes it without moving focus. Choosing a row closes it and leaves focus with the row's own action.
|
|
525
|
+
- Opening one menu closes any other menu that opted in, so two panels are never open together.
|
|
526
|
+
- A menu picture that fails to load is swapped for the initials in `data-initials` on the trigger, including one that failed before `init` ran.
|
|
527
|
+
- ArrowDown, ArrowUp, Home and End move focus between the rows and wrap. This is included and is about fifteen lines. There is no type-ahead and Tab is not trapped.
|
|
528
|
+
|
|
529
|
+
The behaviour is additive. A page with no `data-fk-menu` gets no new listeners, so a page that wires its own launcher or account menu
|
|
530
|
+
is never handled twice. To hand a menu to `shell.js`, remove the page's own wiring for it and add the attribute. The launcher can adopt it the same way.
|
|
531
|
+
`@fikar-ai/design-react` does not render the attribute, because the component owns the behaviour and the two would otherwise both run.
|
|
532
|
+
|
|
533
|
+
#### Jinja macros
|
|
534
|
+
|
|
535
|
+
`templates/jinja/user-menu.html` holds two macros.
|
|
536
|
+
|
|
537
|
+
```jinja
|
|
538
|
+
{% from "fikar/user-menu.html" import user_menu, user_initials %}
|
|
539
|
+
{% set cluster %}
|
|
540
|
+
{{ launcher(apps) }}
|
|
541
|
+
{{ user_menu(email=me.email, sign_out_href=sign_out_url, name=me.name, avatar_url=me.avatar_url, account_href="/account", items=[{"label": "Workspace settings", "href": "/workspace"}]) }}
|
|
542
|
+
{% endset %}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- `user_menu(email, sign_out_href, name='', avatar_url='', account_href='', items=none)`. `email` and `sign_out_href` are required. An empty `account_href` leaves Account settings out.
|
|
546
|
+
`items` is a list with `label` and `href`. It renders `recipes/user-menu.html`, and every text argument is escaped.
|
|
547
|
+
- `user_initials(name, email)` returns the initials by the rule above. `user_menu` uses it, and a host can call it wherever it needs the same letters, for example in a sidebar foot.
|
|
548
|
+
|
|
549
|
+
#### React component
|
|
550
|
+
|
|
551
|
+
`UserMenu` renders `recipes/user-menu.html` and a parity test keeps them equal. It adds no dependency, so there is no Radix menu and no headless library. Open and close are plain React state.
|
|
552
|
+
It closes on Escape with focus returning to the avatar, on a press outside and on choosing a row, and it moves focus with the arrow keys, Home and End. It also closes when focus moves to an element outside it. Another menu that opens closes it, by a click because that click starts with a press outside, and by keyboard because Tab
|
|
553
|
+
to that menu's button moves focus out first.
|
|
554
|
+
|
|
555
|
+
```tsx
|
|
556
|
+
import { UserMenu, UserMenuItem } from '@fikar-ai/design-react';
|
|
557
|
+
import { Link } from 'react-router-dom';
|
|
558
|
+
|
|
559
|
+
<UserMenu
|
|
560
|
+
name={user.name}
|
|
561
|
+
email={user.email}
|
|
562
|
+
avatarUrl={user.avatarUrl}
|
|
563
|
+
accountSettings={{ component: Link, to: '/account' }}
|
|
564
|
+
signOut={{ onSelect: () => { clearLocalState(); signOut(); } }}
|
|
565
|
+
>
|
|
566
|
+
<UserMenuItem component={Link} to="/workspace">Workspace settings</UserMenuItem>
|
|
567
|
+
</UserMenu>
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
| Prop | Meaning |
|
|
571
|
+
|---|---|
|
|
572
|
+
| `email` | Required. |
|
|
573
|
+
| `name` | Full name. Optional. Extra whitespace is collapsed. |
|
|
574
|
+
| `avatarUrl` | Picture. Optional. Without it the avatar shows the initials. |
|
|
575
|
+
| `accountSettings` | Optional. The props of the Account settings link, as `NavItem` takes them: `{ href }` or `{ component: Link, to }`. Leave it out to omit the row. |
|
|
576
|
+
| `signOut` | Required. `{ href }` renders a link and `{ onSelect }` renders a button. Passing both is a type error. |
|
|
577
|
+
| `children` | The app's own rows, as `UserMenuItem` elements. They render after Account settings and before Sign out. |
|
|
578
|
+
|
|
579
|
+
`UserMenuItem` takes `component` and the component's own props, like `NavItem`. Pass the router's `Link`, never `NavLink`. It sets `role="menuitem"` and its classes itself.
|
|
580
|
+
`userInitials(name, email)` is exported for anything else that shows the same letters, and it follows the rule above, including a letter outside the basic plane and a name with extra spaces.
|
|
581
|
+
An `avatarUrl` that fails to load is replaced by the initials, and a new `avatarUrl` gets a fresh try. An image that failed before React hydrated server rendered markup is not caught, because its error event has already fired.
|
|
582
|
+
|
|
583
|
+
#### Added in 1.1.0
|
|
584
|
+
|
|
585
|
+
Everything here is new and opt-in. No existing class, markup, macro or component changed, and the shell recipes only gained the menu in place of their placeholder avatar.
|
|
586
|
+
|
|
587
|
+
- CSS: `.user-menu-btn`, `.user-menu-panel`, `.user-menu-id`, `.user-menu-name`, `.user-menu-email`, `.user-menu-sep`, `.user-menu-item`.
|
|
588
|
+
- `recipes/user-menu.html`, and the user menu in the cluster of both shell recipes.
|
|
589
|
+
- `shell.js` menu behaviour for `data-fk-menu`.
|
|
590
|
+
- Jinja macros `user_menu` and `user_initials`.
|
|
591
|
+
- React `UserMenu`, `UserMenuItem` and `userInitials`.
|
|
592
|
+
|
|
593
|
+
What a surface changes to adopt it. Delete the local user menu, its CSS and its wiring, and render the shared one.
|
|
594
|
+
React apps pass `name`, `email`, `avatarUrl`, `accountSettings` (or nothing) and either `signOut.href` or `signOut.onSelect`, and pass platform-ui's Workspace settings as a child.
|
|
595
|
+
Server rendered pages call `user_menu` and load `shell.js`, or copy the recipe for a page with no template engine and keep its own sign out wiring if Sign out needs code.
|
|
596
|
+
Move `@fikar-ai/design` and `@fikar-ai/design-react` to 1.1.0 together. The React package's peer range on `@fikar-ai/design` stays `>=1.0.0 <2`, because tightening a peer range is a major change (see "Versioning"), so npm will not stop 1.1.0 React from being installed beside a 1.0.x CSS package. `UserMenu` would then render without its styles.
|
|
597
|
+
|
|
598
|
+
### Theme menu (since 1.2.0)
|
|
599
|
+
|
|
600
|
+
The button and panel for choosing Light, Dark or System, second in the shell cluster, after the launcher and before the user menu. It stores nothing.
|
|
601
|
+
The app passes the theme it has, and applies and saves the choice in its own way.
|
|
602
|
+
|
|
603
|
+
Markup: `recipes/theme-menu.html` (`@fikar-ai/design/recipes/theme-menu.html`). It is also in the cluster of `recipes/shell.html` and `recipes/shell-no-sidebar.html`. Shown with System chosen.
|
|
604
|
+
|
|
605
|
+
```html
|
|
606
|
+
<div class="launcher" data-fk-menu>
|
|
607
|
+
<button class="launcher-btn theme-menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Choose theme">
|
|
608
|
+
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
609
|
+
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
|
610
|
+
</svg>
|
|
611
|
+
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
612
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
613
|
+
</svg>
|
|
614
|
+
</button>
|
|
615
|
+
<div class="launcher-panel theme-menu-panel" role="menu" aria-label="Theme">
|
|
616
|
+
<button class="launcher-item theme-menu-item" type="button" role="menuitemradio" aria-checked="false" data-value="light">Light</button>
|
|
617
|
+
<button class="launcher-item theme-menu-item" type="button" role="menuitemradio" aria-checked="false" data-value="dark">Dark</button>
|
|
618
|
+
<button class="launcher-item theme-menu-item current" type="button" role="menuitemradio" aria-checked="true" data-value="system">System</button>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
- The rows are in the order Light, Dark, System. The chosen row has `.current`, `role="menuitemradio"` and `aria-checked="true"`, and the others have `aria-checked="false"`. There is no dot marker and no label row. The menu is named with `aria-label="Theme"`, and each row's `data-value` is the value the page receives.
|
|
624
|
+
- The trigger shows the sun in a light theme and the moon in a dark one. Both icons are inline in the button, and the CSS shows one from the theme on the root, `.dark` or `[data-theme='dark']`, the same two switches the logo rules and `tokens.css` use.
|
|
625
|
+
The right icon is there on first paint and no script decides it. The tokens have no `prefers-color-scheme` rule, and neither does `components.css`, so a host that leaves "system" to CSS resolves it to one of those switches itself. The icon follows the theme that is applied, so with System chosen it shows what System resolved to.
|
|
626
|
+
A page that leaves System to CSS, as the account console does with `data-theme="system"` and a media query, shows the sun on a dark operating system unless it adds its own rule, in that same media query, that hides `.theme-icon-sun` and shows `.theme-icon-moon` under `[data-theme='system']`.
|
|
627
|
+
- Classes, all new in 1.2.0: `.theme-menu-btn` (on a `.launcher-btn`, which gives the 36px size and the hover), `.theme-icon-sun`, `.theme-icon-moon`, `.theme-menu-panel` (160px minimum, against 260px for the launcher) and `.theme-menu-item` (on a `.launcher-item`, with the resets a `button` row needs and a focus ring).
|
|
628
|
+
|
|
629
|
+
#### How a plain page learns about a choice
|
|
630
|
+
|
|
631
|
+
The page listens for one event. `shell.js` sends `fk-theme-change` from the menu root when a person chooses a row that was not already chosen. It bubbles, and `detail.value` is `"light"`, `"dark"` or `"system"`.
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
document.addEventListener('fk-theme-change', function (e) {
|
|
635
|
+
applyTheme(e.detail.value); // toggle .dark on the root, or resolve "system" first
|
|
636
|
+
saveTheme(e.detail.value); // the page's own store, and PATCH /auth/me {theme} if it has one
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
- `shell.js` marks the chosen row itself before it sends the event, so the page does not need to touch the menu. The menu closes and focus returns to the button. Choosing the row that is already chosen closes the menu and sends nothing.
|
|
641
|
+
- The page sets which row is current in the markup it renders. The macro does it from its `value`, and a page that changes the theme by other means, such as a saved theme arriving after load, moves `.current` and `aria-checked` itself on the three rows. There is no function for it, so there is nothing more to keep stable.
|
|
642
|
+
- The behaviour is otherwise the user menu's, in `shell.js` under `data-fk-menu`. Enter and Space open it, Escape closes it with focus on the button, a press outside closes it, opening any other menu closes it, and ArrowUp, ArrowDown, Home and End move between the rows.
|
|
643
|
+
|
|
644
|
+
#### React component
|
|
645
|
+
|
|
646
|
+
`ThemeMenu` renders `recipes/theme-menu.html`, keeps no state and owns its open and close behaviour, so it does not render `data-fk-menu`. The app passes the theme it has and a handler.
|
|
647
|
+
|
|
648
|
+
```tsx
|
|
649
|
+
import { ThemeMenu } from '@fikar-ai/design-react';
|
|
650
|
+
|
|
651
|
+
<ThemeMenu value={theme} onChange={(next) => { applyTheme(next); saveTheme(next); }} />
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
| Prop | Meaning |
|
|
655
|
+
|---|---|
|
|
656
|
+
| `value` | Required. `'light'`, `'dark'` or `'system'`. The chosen row follows it, so a menu whose `value` does not change keeps showing the same row. |
|
|
657
|
+
| `onChange` | Required. Called with the row the person chose, unless it is the one already chosen. The app applies and saves the theme, in whatever store it uses. |
|
|
658
|
+
|
|
659
|
+
The sun and moon are the CSS's job, from the theme on the root, so the component takes no theme for its icon. Choosing a row closes the menu and returns focus to the button.
|
|
660
|
+
|
|
661
|
+
#### Jinja macro
|
|
662
|
+
|
|
663
|
+
```jinja
|
|
664
|
+
{% from "fikar/theme-menu.html" import theme_menu %}
|
|
665
|
+
{{ theme_menu(value=me.theme) }}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
`theme_menu(value='system')` in `templates/jinja/theme-menu.html` renders `recipes/theme-menu.html` with the row for `value` chosen. A value that is not `light` or `dark`, including none, shows System, which is what a person with no saved theme gets.
|
|
669
|
+
|
|
670
|
+
### Added in 1.2.0
|
|
671
|
+
|
|
672
|
+
Everything here is new and opt-in, with two exceptions that are called out below. No macro output, recipe markup or component that existed in 1.1.0 changed.
|
|
673
|
+
|
|
674
|
+
- The theme menu. CSS: `.theme-menu-btn`, `.theme-icon-sun`, `.theme-icon-moon`, `.theme-menu-panel`, `.theme-menu-item`. `recipes/theme-menu.html`, the Jinja macro `theme_menu`, and the React `ThemeMenu` (see "Theme menu").
|
|
675
|
+
- The React `AppLauncher` (see "App launcher recipe").
|
|
676
|
+
- `data_fk_menu` on the `launcher` macro. The default output is byte for byte what 1.1.0 rendered, and `recipes/launcher.html` has the same markup, so a page that wires its launcher itself and takes this version changes nothing.
|
|
677
|
+
- `shell.js`: `role="menuitemradio"` rows in a `data-fk-menu` menu, and the bubbling `fk-theme-change` event. A page with no such row sees no difference.
|
|
678
|
+
- The cluster of `recipes/shell.html` and `recipes/shell-no-sidebar.html` now holds the launcher, the theme menu and the user menu, in that order, all with `data-fk-menu`.
|
|
679
|
+
- "Moving a page to shell.js", which says how a page moves off its own wiring and that a page must not mix shared and hand wired menus.
|
|
680
|
+
|
|
681
|
+
Two changes restyle a class that existed before 1.2.0, and both are in their own commits in the pull request, so either can be reverted alone or moved to the next major release if that is the reading of "Versioning".
|
|
682
|
+
|
|
683
|
+
- `.launcher-btn:focus-visible` and `.launcher-item[aria-current]:focus-visible` now draw the 2px ring in `--ring` that the user menu rows use. The button had the browser's default ring, and a focused current row showed no mark, because its accent background wins over the focus background. Only a keyboard focus changes.
|
|
684
|
+
- The launcher panel that is the first child of `.cluster` shrinks below 366px wide to keep 16px from the left edge of the viewport. Its left edge was 6px off screen at 360px and 46px at 320px. No other width changes, and the user menu and theme menu panels are not touched.
|
|
685
|
+
|
|
686
|
+
No markup changes.
|
|
687
|
+
|
|
688
|
+
What a surface changes to adopt it. Delete the local launcher, theme control, their CSS and their wiring, and render the shared ones.
|
|
689
|
+
React apps pass `apps` and `currentId` to `AppLauncher`, and `value` and `onChange` to `ThemeMenu`, keep saving the theme where they save it today, and put the three in `headerActions` before `UserMenu`.
|
|
690
|
+
Server rendered pages call `launcher(apps, data_fk_menu=true)` and `theme_menu(value=...)`, listen for `fk-theme-change`, and load `shell.js`, having removed the page's own wiring for every menu in the bar.
|
|
691
|
+
Move `@fikar-ai/design` and `@fikar-ai/design-react` to 1.2.0 together. The peer range on `@fikar-ai/design` stays `>=1.0.0 <2`, so npm will not stop 1.2.0 React from being installed beside an older CSS package. The new components would then render without their styles.
|
|
692
|
+
|
|
693
|
+
### Fixed in 1.2.1
|
|
694
|
+
|
|
695
|
+
The React `AppLauncher`, `ThemeMenu` and `UserMenu` now close when keyboard focus moves to an element outside them. In 1.2.0 they closed only on Escape and on a press outside,
|
|
696
|
+
so opening a menu by keyboard (Tab to its button, then Enter) left the menu that was already open showing, and two panels were open together. `shell.js` never had this, because a
|
|
697
|
+
button click closes every other open menu and Enter on a button fires a click. Tabbing away from an open menu now closes it too. No markup, class or prop changed, and nothing has to
|
|
698
|
+
change to upgrade. Move `@fikar-ai/design` and `@fikar-ai/design-react` to 1.2.1 together. The peer range on `@fikar-ai/design` stays `>=1.0.0 <2`.
|
|
699
|
+
|
|
700
|
+
### Search (since 1.3.0)
|
|
701
|
+
|
|
702
|
+
The search box, its results list, filter chips, entity badges and a segmented control, as CSS recipes with thin React wrappers. Before this each surface wrote its own box, and the graph explorer would have been the fourth copy. The recipes are the contract. A plain HTML page copies the markup, and `@fikar-ai/design-react` renders the same markup, kept equal by the parity tests.
|
|
703
|
+
|
|
704
|
+
| Recipe | Markup |
|
|
705
|
+
|---|---|
|
|
706
|
+
| Search box | `recipes/search.html` |
|
|
707
|
+
| Results list | `recipes/search-results.html` |
|
|
708
|
+
| Filter chips | `recipes/filter-chip.html` |
|
|
709
|
+
| Entity badges | `recipes/entity-badge.html` |
|
|
710
|
+
| Segmented control | `recipes/segmented.html` |
|
|
711
|
+
|
|
712
|
+
There are no Jinja macros for these. The markup is short and the only host that renders it from a template, the graph explorer, copies the recipe.
|
|
713
|
+
|
|
714
|
+
#### Search box
|
|
715
|
+
|
|
716
|
+
Shown at the top bar size, empty.
|
|
717
|
+
|
|
718
|
+
```html
|
|
719
|
+
<div class="search search-bar">
|
|
720
|
+
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
721
|
+
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
722
|
+
</svg>
|
|
723
|
+
<input class="inp" type="search" placeholder="Search" aria-label="Search" autocomplete="off" data-fk-search>
|
|
724
|
+
<button class="search-clear" type="button" aria-label="Clear search">
|
|
725
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
726
|
+
<path d="M18 6 6 18M6 6l12 12"/>
|
|
727
|
+
</svg>
|
|
728
|
+
</button>
|
|
729
|
+
<kbd class="search-kbd">/</kbd>
|
|
730
|
+
</div>
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
- `.search-bar` fills the top bar's start slot up to 448px. `.search-panel` fills a sidebar or a panel, with a 14px icon and 13px text. Both are 36px high, as the two hand made boxes were. A bare `.search` is still the fixed 280px box that 1.2.1 shipped, and its three rules did not change.
|
|
734
|
+
- The icons are inline svg, so a page needs no icon library. `.search .search-icon` is the leading one, and the clear button's own icon is reset so the leading icon rule does not move it.
|
|
735
|
+
- The input needs a placeholder. CSS shows the hint while the box is empty and the clear button once there is text, from `:placeholder-shown`, so no script keeps them in step. Both sit at the right edge, and the input reserves 48px there only when a `.search-clear` or `.search-kbd` is a direct child of `.search`.
|
|
736
|
+
- The hint is a `kbd` with short text, such as `/` or `⌘K`. The page says what it registered. It is hidden below 768px, where there is no keyboard to press it on. The recipe has no rule that hides the whole box on a phone. platform-ui does that with its own wrapper, and a page that wants it does the same.
|
|
737
|
+
- The native clear control of `type="search"` is hidden, so it never shows beside `.search-clear`.
|
|
738
|
+
- A page that wants no clear button or no hint leaves that element out, and the input keeps its normal padding when neither is there.
|
|
739
|
+
|
|
740
|
+
#### Registering the box and the shortcut
|
|
741
|
+
|
|
742
|
+
A page registers its search box by putting `data-fk-search` on the input. `shell.js` then focuses it, and selects its text, when the person presses `/` or Cmd-K (Ctrl-K on other systems).
|
|
743
|
+
|
|
744
|
+
- The first registered box in the document that is on screen wins. A second one is never chosen, and a box that is hidden at the current width (`display: none`, so it has no layout box) is skipped, so a page that hides its top bar box on a phone does not lose the key. When no box is on screen the key is left alone.
|
|
745
|
+
- `/` is ignored while the person is typing in an `input`, a `textarea`, a `select` or an editable element, and with Ctrl, Cmd or Alt held. Cmd-K and Ctrl-K work from anywhere, including inside a field, because they cannot be typed. Both modifiers are accepted on every platform, so nothing reads the operating system. A key another handler already took, and one pressed during an IME composition, are ignored.
|
|
746
|
+
- `shell.js` calls `preventDefault` on the keys it takes. Without it the `/` would be typed into the box that has just taken focus, and Ctrl-K would reach the browser's own address bar.
|
|
747
|
+
- The same script clears the box when a `.search-clear` button is pressed, keeps focus in the box, and sends a bubbling `input` event so the page's own listeners see the empty value.
|
|
748
|
+
- Nothing is bound on a page with no `data-fk-search` and no `.search-clear`. A box your code inserts later needs `FikarShell.init(root)`, where `root` is the box, the container that holds it or the document.
|
|
749
|
+
- React apps do not load `shell.js`. `SearchBox` never renders `data-fk-search`, and the app calls `useShortcut(['/', 'mod+k'], () => ref.current?.focus())` with the ref it gave the box.
|
|
750
|
+
|
|
751
|
+
#### Search results
|
|
752
|
+
|
|
753
|
+
The listbox for typeahead results, open under the box, and the same listbox with nothing to show.
|
|
754
|
+
|
|
755
|
+
```html
|
|
756
|
+
<div class="search search-bar">
|
|
757
|
+
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
758
|
+
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
759
|
+
</svg>
|
|
760
|
+
<input class="inp" type="search" value="a" placeholder="Search" aria-label="Search" autocomplete="off" data-fk-search
|
|
761
|
+
role="combobox" aria-expanded="true" aria-controls="search-results" aria-autocomplete="list" aria-activedescendant="search-result-aisha">
|
|
762
|
+
<button class="search-clear" type="button" aria-label="Clear search">
|
|
763
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
764
|
+
<path d="M18 6 6 18M6 6l12 12"/>
|
|
765
|
+
</svg>
|
|
766
|
+
</button>
|
|
767
|
+
<kbd class="search-kbd">/</kbd>
|
|
768
|
+
<div class="launcher-panel search-results" id="search-results" role="listbox" aria-label="Results">
|
|
769
|
+
<div class="search-row" id="search-result-aisha" role="option" aria-selected="true">
|
|
770
|
+
<span class="search-row-title">Aisha Rahman</span>
|
|
771
|
+
<span class="badge b-person">Person</span>
|
|
772
|
+
<span class="search-row-meta">12</span>
|
|
773
|
+
<span class="search-row-desc">Head of platform, owns the Q3 roadmap</span>
|
|
774
|
+
</div>
|
|
775
|
+
<div class="search-row" id="search-result-acme" role="option" aria-selected="false">
|
|
776
|
+
<span class="search-row-title">Acme Corp</span>
|
|
777
|
+
<span class="badge b-organization">Organization</span>
|
|
778
|
+
<span class="search-row-meta">8</span>
|
|
779
|
+
<span class="search-row-desc">Largest customer, renewal due in October</span>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="search-row" id="search-result-planning" role="option" aria-selected="false">
|
|
782
|
+
<span class="search-row-title">Q3 planning</span>
|
|
783
|
+
<span class="badge b-event">Event</span>
|
|
784
|
+
<span class="search-row-meta">5</span>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<div class="launcher-panel search-results" id="search-results-empty" role="listbox" aria-label="Results">
|
|
790
|
+
<div class="search-empty" role="option" aria-disabled="true" aria-selected="false">No matches</div>
|
|
791
|
+
</div>
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
- The popover is a `.launcher-panel`, so its surface, border, shadow and stacking are shared. `.search` is already its positioned parent. `.search-results` makes it span the box (`left: 0; right: 0`), at most 360px high with its own scroll. It shows unless it carries `hidden`, and the page keeps `aria-expanded` on the input in step.
|
|
795
|
+
- Focus stays in the input. The active row is the one named by `aria-activedescendant` on the input, and it also has `aria-selected="true"`. That attribute is the only hook that draws it, with a muted fill and a bar on the left. The other rows have `aria-selected="false"`. Row ids are the page's and must be unique on the page.
|
|
796
|
+
- A row is the title, then an optional entity badge and count on the same line, then an optional one line description under them. Each part is cut with an ellipsis and never wraps.
|
|
797
|
+
- The empty state is one disabled option inside the listbox, so the list stays valid, and it is never the active row.
|
|
798
|
+
- The keys are not driven by `shell.js`. In plain HTML the page handles ArrowDown, ArrowUp, Enter and Escape on the input, keeps the active row in view with `scrollIntoView({ block: 'nearest' })`, and cancels `mousedown` on the list so the box does not lose focus before a click lands. `SearchResults` does all of this in React.
|
|
799
|
+
- In the no-sidebar shell the start slot scrolls sideways and so clips anything hanging below it, which includes this list. Use the box in the page content there, or in the shell with a sidebar.
|
|
800
|
+
|
|
801
|
+
#### Filter chips
|
|
802
|
+
|
|
803
|
+
```html
|
|
804
|
+
<button class="pill p-toggle" type="button" aria-pressed="false">Person</button>
|
|
805
|
+
<button class="pill p-toggle" type="button" aria-pressed="true">Organization</button>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
A `.pill` on a `button`, with `.p-toggle`. `aria-pressed` is the state and the only style hook, and the page keeps it in step. A chip changes what a list shows, so it is a toggle button and not a link or a checkbox. Put a group in a wrapper with `role="group"` and an `aria-label`.
|
|
809
|
+
|
|
810
|
+
#### Entity badges
|
|
811
|
+
|
|
812
|
+
```html
|
|
813
|
+
<span class="badge b-person">Person</span>
|
|
814
|
+
<span class="badge b-organization">Organization</span>
|
|
815
|
+
<span class="badge b-event">Event</span>
|
|
816
|
+
<span class="badge b-geo">Geo</span>
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
`.badge` plus `.b-person`, `.b-organization`, `.b-event` or `.b-geo`, for the four kinds of entity in the knowledge graph. The colours are new roles in `tokens.css`, defined in `:root` and `.dark` like the status roles, so dark needs nothing more. The hues are the ones chat's Sources graph draws its nodes with. A canvas cannot inherit CSS variables, so it reads the same roles from the computed style and needs no palette of its own.
|
|
820
|
+
|
|
821
|
+
| Role | Light | Dark | Chat's graph hex |
|
|
822
|
+
|---|---|---|---|
|
|
823
|
+
| `--entity-person` | `260 51% 46%` | `260 51% 78%` | `#a58bd9` |
|
|
824
|
+
| `--entity-organization` | `176 72% 22%` | `176 49% 55%` | `#3fb8af` |
|
|
825
|
+
| `--entity-event` | `222 70% 44%` | `222 81% 76%` | `#6f96f0` |
|
|
826
|
+
| `--entity-geo` | `38 80% 26%` | `38 67% 58%` | `#d9a13f` |
|
|
827
|
+
|
|
828
|
+
The badge text is 11px, so it is held to 4.5:1 on its own tint (the role at 13% over the surface). Chat's hexes are mid tones drawn on a canvas, and text needs a darker value on a light surface, so the light values keep the hue and are darker. The contrast test covers each pairing on the card, the popover and the muted fill, in both themes. Chat also draws `TEAM` in the organization colour and `RISK` in red. Neither has a badge here, and `TEAM` can use `.b-organization`.
|
|
829
|
+
|
|
830
|
+
#### Segmented control
|
|
831
|
+
|
|
832
|
+
```html
|
|
833
|
+
<div class="segmented" role="radiogroup" aria-label="Search mode">
|
|
834
|
+
<button class="segmented-item" type="button" role="radio" aria-checked="true">Find</button>
|
|
835
|
+
<button class="segmented-item" type="button" role="radio" aria-checked="false">Ask</button>
|
|
836
|
+
</div>
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
A two mode switch such as Find or Ask, and it fits up to three or four short choices. It is a `radiogroup` of buttons with `role="radio"`, because exactly one is chosen, and `aria-checked` is the state and the only style hook. The page sets `"true"` on the chosen item and `"false"` on the rest. All the buttons are in the tab order and Space or Enter chooses one, so it needs no script. Arrow keys between the items are not implemented. There is no React component for it, because the state is the app's and the markup is one line per item.
|
|
840
|
+
|
|
841
|
+
#### React components
|
|
842
|
+
|
|
843
|
+
```tsx
|
|
844
|
+
import { useRef, useState } from 'react';
|
|
845
|
+
import { EntityBadge, FilterChip, SearchBox, SearchResults, useShortcut } from '@fikar-ai/design-react';
|
|
846
|
+
|
|
847
|
+
function GlobalSearch() {
|
|
848
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
849
|
+
const [value, setValue] = useState('');
|
|
850
|
+
const [open, setOpen] = useState(false);
|
|
851
|
+
const [activeId, setActiveId] = useState<string>();
|
|
852
|
+
useShortcut(['/', 'mod+k'], () => { ref.current?.focus(); ref.current?.select(); });
|
|
853
|
+
|
|
854
|
+
return (
|
|
855
|
+
<SearchBox
|
|
856
|
+
ref={ref}
|
|
857
|
+
value={value}
|
|
858
|
+
onChange={(next) => { setValue(next); setOpen(next !== ''); }}
|
|
859
|
+
onClear={() => { setValue(''); setOpen(false); }}
|
|
860
|
+
placeholder="Search integrations, events, logs"
|
|
861
|
+
aria-label="Global search"
|
|
862
|
+
shortcutHint="⌘K"
|
|
863
|
+
listboxId="global-results"
|
|
864
|
+
expanded={open}
|
|
865
|
+
activeId={activeId}
|
|
866
|
+
>
|
|
867
|
+
{open ? (
|
|
868
|
+
<SearchResults
|
|
869
|
+
id="global-results"
|
|
870
|
+
items={items}
|
|
871
|
+
activeId={activeId}
|
|
872
|
+
onActiveChange={setActiveId}
|
|
873
|
+
onSelect={(item) => { go(item.id); setOpen(false); }}
|
|
874
|
+
onClose={() => setOpen(false)}
|
|
875
|
+
inputRef={ref}
|
|
876
|
+
emptyText="No matches"
|
|
877
|
+
/>
|
|
878
|
+
) : null}
|
|
879
|
+
</SearchBox>
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
`SearchBox` props. It is a `forwardRef` component and the ref is the input's. Every other input prop, such as `id`, `name`, `onKeyDown` and `onFocus`, is passed to the input. There is no `className` (KAN-311).
|
|
885
|
+
|
|
886
|
+
| Prop | Meaning |
|
|
887
|
+
|---|---|
|
|
888
|
+
| `value`, `onChange` | Required. Controlled. `onChange` gets the new text, not the event. |
|
|
889
|
+
| `placeholder` | Required, because CSS picks the hint or the clear button from whether it is showing. |
|
|
890
|
+
| `aria-label` | Required, since the box has no visible label. |
|
|
891
|
+
| `size` | `'bar'` (default) or `'panel'`. |
|
|
892
|
+
| `shortcutHint` | Text of the trailing `kbd`. No hint when omitted. |
|
|
893
|
+
| `onClear` | Renders the clear button. Called on press, then focus returns to the input. Usually `() => setValue('')`. |
|
|
894
|
+
| `listboxId` | The `id` of the `SearchResults` under it. This makes the input a `combobox` with `aria-controls` and `aria-autocomplete="list"`. Without it the input is a plain search input. |
|
|
895
|
+
| `expanded` | Whether the list is open, for `aria-expanded`. Read only with `listboxId`. |
|
|
896
|
+
| `activeId` | The active row's id, for `aria-activedescendant`. Read only with `listboxId`. |
|
|
897
|
+
| `children` | Put `SearchResults` here, so the box positions it. |
|
|
898
|
+
|
|
899
|
+
`SearchResults` props.
|
|
900
|
+
|
|
901
|
+
| Prop | Meaning |
|
|
902
|
+
|---|---|
|
|
903
|
+
| `id` | Required. The listbox id, the same string as `listboxId` on the box. |
|
|
904
|
+
| `items` | Required. Each has `id`, `title` and optionally `type` (`'PERSON'`, `'ORGANIZATION'`, `'EVENT'` or `'GEO'`, which shows the badge), `count` and `description`. The `id` becomes the row's DOM id, so it is unique on the page. |
|
|
905
|
+
| `activeId`, `onActiveChange` | The active row, kept by the app, and called with the row the arrow keys moved to. |
|
|
906
|
+
| `onSelect` | Called with the item that was clicked, or the active one when Enter is pressed. |
|
|
907
|
+
| `onClose` | Called on Escape. The app hides the list. Focus never left the input, so it stays there. |
|
|
908
|
+
| `inputRef` | The ref given to `SearchBox`. The keys are heard on the input. |
|
|
909
|
+
| `emptyText` | Required. What the list says when `items` is empty. |
|
|
910
|
+
| `aria-label` | Names the listbox. Defaults to "Results". |
|
|
911
|
+
|
|
912
|
+
`SearchResults` shows while it is mounted, so the app mounts it while the list is open. While mounted it makes ArrowDown and ArrowUp move the active row (wrapping, starting from the last row on ArrowUp), Enter select it and Escape ask to close. It does not use the roving focus of the menus, because that moves focus onto the rows and a combobox keeps it in the input. A press on the list keeps focus in the input.
|
|
913
|
+
|
|
914
|
+
| Export | Meaning |
|
|
915
|
+
|---|---|
|
|
916
|
+
| `FilterChip` | `pressed`, `onToggle(next)`, children. Renders `recipes/filter-chip.html`. |
|
|
917
|
+
| `EntityBadge` | `type`, one of the four upper case names. Renders `recipes/entity-badge.html` with the label in words. |
|
|
918
|
+
| `useShortcut(keys, handler)` | `keys` is one combo or a list, such as `'/'` or `'mod+k'`. `mod` is Cmd or Ctrl, and Alt never matches. It calls `preventDefault`, ignores a bare key while the person is typing in a field, and ignores a key already handled or pressed during an IME composition. The latest `handler` is always called, and an inline `keys` array does not resubscribe. |
|
|
919
|
+
|
|
920
|
+
#### Drift check rule
|
|
921
|
+
|
|
922
|
+
Apps guard the shared UI in `scripts/check-shared-ui.mjs`. This rule fails on a hand made search box, which is a lucide `Search` icon in a file that also imports the shadcn `Input`, or a bare `type="search"` input. The package renders its own `type="search"` from `node_modules`, which the script does not scan. Add it beside the other checks and report each hit as a violation with its file and line.
|
|
923
|
+
|
|
924
|
+
```js
|
|
925
|
+
// A search box comes from @fikar-ai/design-react (SearchBox), never from a lucide icon and an Input.
|
|
926
|
+
const SEARCH_ICON_IMPORT = /import\s*\{[^}]*\bSearch\b[^}]*\}\s*from\s*['"]lucide-react['"]/;
|
|
927
|
+
const SHADCN_INPUT_IMPORT = /from\s*['"]@\/components\/ui\/input['"]/;
|
|
928
|
+
const BARE_SEARCH_INPUT = /type\s*[=:]\s*\{?\s*['"]search['"]/g;
|
|
929
|
+
|
|
930
|
+
function findSearchDrift(text) {
|
|
931
|
+
const hits = [];
|
|
932
|
+
if (SEARCH_ICON_IMPORT.test(text) && SHADCN_INPUT_IMPORT.test(text)) {
|
|
933
|
+
hits.push({ index: text.search(SEARCH_ICON_IMPORT), message: 'a lucide Search icon beside a shadcn Input: use SearchBox from @fikar-ai/design-react' });
|
|
934
|
+
}
|
|
935
|
+
for (const match of text.matchAll(BARE_SEARCH_INPUT)) {
|
|
936
|
+
hits.push({ index: match.index, message: 'a bare type="search" input: use SearchBox from @fikar-ai/design-react' });
|
|
937
|
+
}
|
|
938
|
+
return hits;
|
|
939
|
+
}
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
A file that needs both the `Search` icon for something else and a plain `Input` is flagged too. Move the icon use into its own file, or import a different icon.
|
|
943
|
+
|
|
944
|
+
#### Added in 1.3.0
|
|
945
|
+
|
|
946
|
+
Everything here is new and opt-in. No existing class, markup, macro, prop or default changed, no existing token changed, and the three `.search` rules from 1.2.1 are byte for byte as they were.
|
|
947
|
+
|
|
948
|
+
- CSS, search: `.search-bar`, `.search-panel`, `.search-icon`, `.search-clear`, `.search-kbd`, `.search-results`, `.search-row`, `.search-row-title`, `.search-row-meta`, `.search-row-desc`, `.search-empty`, `.p-toggle`, `.segmented`, `.segmented-item`, `.b-person`, `.b-organization`, `.b-event`, `.b-geo`.
|
|
949
|
+
- CSS, detail and list: `.detail-panel`, `.detail-close`, `.detail-type`, `.detail-dot`, `.detail-dot-person`, `.detail-dot-organization`, `.detail-dot-event`, `.detail-dot-geo`, `.detail-title`, `.detail-desc`, `.detail-section`, `.detail-heading`, `.detail-row`, `.detail-row-meta`, `.detail-chips`, `.detail-chip`, `.list-row`, `.list-row-title`, `.list-row-meta`, `.day-sep`, `.b-success`, `.b-warning`, `.b-destructive`, `.b-muted`, `.fk-time`.
|
|
950
|
+
- Tokens: `--entity-person`, `--entity-organization`, `--entity-event`, `--entity-geo`.
|
|
951
|
+
- Recipes: `search.html`, `search-results.html`, `filter-chip.html`, `entity-badge.html`, `segmented.html`, `detail-panel.html`, `list-row.html`, `status-badge.html`, `time.html`.
|
|
952
|
+
- `shell.js`: `data-fk-search`, the `/` and Cmd-K or Ctrl-K shortcut, the `.search-clear` handler, and `FikarShell.formatRelative` and `FikarShell.formatExact`.
|
|
953
|
+
- React: `SearchBox`, `SearchResults`, `FilterChip`, `EntityBadge`, `useShortcut`, `DetailPanel`, `DetailSection`, `ListRow`, `DaySeparator`, `StatusBadge`, `RelativeTime`, `formatRelative`, `formatExact` and their prop types.
|
|
954
|
+
|
|
955
|
+
What each surface changes to adopt it. Move `@fikar-ai/design` and `@fikar-ai/design-react` to 1.3.0 together, and merge nothing that adds a local search box, detail panel, list row, status badge or time formatter before that. The peer range on `@fikar-ai/design` stays `>=1.0.0 <2`.
|
|
956
|
+
|
|
957
|
+
- platform-ui replaces `GlobalSearch` with `SearchBox` (`shortcutHint="⌘K"`, `placeholder="Search integrations, events, logs"`), keeps its own `hidden md:block` wrapper, and deletes the lucide `Search`, the shadcn `Input`, the hand made `kbd` and the `keydown` effect for `useShortcut(['/', 'mod+k'], ...)`. It gains `/` with that. It has no results list yet, and `SearchResults` is what it uses when it has one.
|
|
958
|
+
- chat replaces the box in `conversations-nav.tsx` with `<SearchBox size="panel" ...>`, passing the `searchRef` it already has as the `ref`. It filters a list under the box and needs no results component. It can render `EntityBadge` in the Sources panel and read the `--entity-*` roles for the graph canvas in place of its hex table.
|
|
959
|
+
- The crs explorer copies `recipes/search.html`, the results recipe, the chips, badges and segmented control from its vendored package, adds `data-fk-search` to its input and loads `shell.js`, then writes the ArrowDown, ArrowUp, Enter and Escape handling and the result rendering itself. The find or ask mode is the segmented control, and it sets `aria-checked` itself. It must not put the box in the start slot of the no-sidebar shell, for the reason under "Search results".
|
|
960
|
+
|
|
961
|
+
### Detail panel, list row, status badge and relative time (since 1.3.0)
|
|
962
|
+
|
|
963
|
+
Four pieces that the graph explorer needs and that chat and platform-ui already have, hand made, in their own way. As with search, the recipes are the contract. A plain HTML page copies the markup, and `@fikar-ai/design-react` renders the same markup, kept equal by the parity tests. Adding them is a minor release, and nothing existing changed.
|
|
964
|
+
|
|
965
|
+
| Recipe | Markup |
|
|
966
|
+
|---|---|
|
|
967
|
+
| Detail panel | `recipes/detail-panel.html` |
|
|
968
|
+
| List row and day separator | `recipes/list-row.html` |
|
|
969
|
+
| Status badges | `recipes/status-badge.html` |
|
|
970
|
+
| Relative time | `recipes/time.html` |
|
|
971
|
+
|
|
972
|
+
The class names carry a prefix, as `.search-row` does, because chat and crs both have local `.panel`, `.row` and `.chip` and the mockup uses them. The package uses `.detail-*` for the panel, `.list-row-*` and `.day-sep` for the list, `.b-success`, `.b-warning`, `.b-destructive` and `.b-muted` for the status badges (the `.b-*` family of `.badge`), and `.fk-time`, like `.fk-tooltip` and `.fk-table`, for the time. None of these names is used by `components.css` before 1.3.0, and none is one of the apps' local names.
|
|
973
|
+
|
|
974
|
+
#### Detail panel
|
|
975
|
+
|
|
976
|
+
The surface that describes one thing next to a list or over a canvas, shown for a person in the graph.
|
|
977
|
+
|
|
978
|
+
```html
|
|
979
|
+
<aside class="detail-panel" aria-labelledby="detail-title">
|
|
980
|
+
<button class="detail-close" type="button" aria-label="Close">
|
|
981
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
982
|
+
<path d="M18 6 6 18M6 6l12 12"/>
|
|
983
|
+
</svg>
|
|
984
|
+
</button>
|
|
985
|
+
<p class="detail-type"><i class="detail-dot detail-dot-person" aria-hidden="true"></i>Person</p>
|
|
986
|
+
<h2 class="detail-title" id="detail-title">John</h2>
|
|
987
|
+
<p class="detail-desc">Prospect at Waterfield evaluating Fikar for support-desk knowledge, personal memory and live call support.</p>
|
|
988
|
+
<section class="detail-section">
|
|
989
|
+
<h3 class="detail-heading">Strongest connections</h3>
|
|
990
|
+
<a class="detail-row" href="#"><span>Fikar</span><span class="detail-row-meta">evaluating · 57</span></a>
|
|
991
|
+
<div class="detail-row"><span>Fayaz</span><span class="detail-row-meta">discussed with · 42</span></div>
|
|
992
|
+
<div class="detail-row"><span>Rana Waqas</span><span class="detail-row-meta">asked about transcripts · 23</span></div>
|
|
993
|
+
</section>
|
|
994
|
+
<section class="detail-section">
|
|
995
|
+
<h3 class="detail-heading">Said in 2 calls</h3>
|
|
996
|
+
<div class="detail-row"><span>Waterfield demo call <span class="detail-row-meta">· 30 Sep</span></span><a href="#">Open call</a></div>
|
|
997
|
+
<div class="detail-row"><span>COO Q2 close review <span class="detail-row-meta">· 12 Sep</span></span><a href="#">Open call</a></div>
|
|
998
|
+
</section>
|
|
999
|
+
<section class="detail-section">
|
|
1000
|
+
<h3 class="detail-heading">Read the words</h3>
|
|
1001
|
+
<div class="detail-chips">
|
|
1002
|
+
<span class="detail-chip">4 passages</span>
|
|
1003
|
+
<a class="detail-chip" href="#">Ask in chat</a>
|
|
1004
|
+
</div>
|
|
1005
|
+
</section>
|
|
1006
|
+
</aside>
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
- The panel is the surface only. It has a card fill, a border, a 12px radius, 18px of padding and its own scroll. Where it sits is the page's. The crs explorer places it with `position: absolute; top: 14px; right: 14px; bottom: 14px` on a wrapper, and chat places it as a flex sibling that becomes a fixed full screen layer under `md`. The recipe has no position, offsets or height for that reason.
|
|
1010
|
+
- It is 340px wide and never wider than its container, and below 768px it fills its container. On a phone the page gives it the whole width (a wrapper with `left: 14px; right: 14px`, or a fixed layer), and the panel follows.
|
|
1011
|
+
- The close button is a real `button` with `aria-label="Close"`, 28px, at the top right, with a keyboard focus ring. Leave it out for a panel that cannot be closed. It only does what the page wires to it, so the page hides or removes the panel.
|
|
1012
|
+
- The type line is a dot and the type in words, drawn in capitals. `.detail-dot-person`, `-organization`, `-event` and `-geo` are the `--entity-*` roles from the search work, so they follow the theme and match chat's Sources graph. A dot with none of them is muted.
|
|
1013
|
+
- The title is Charter with the preset's serif stack, and it names the panel through `aria-labelledby`. Charter loads only on a page that imports `fonts.css`, and without it the title is the next serif on the stack. The `id` is the page's and must be unique.
|
|
1014
|
+
- A section is a `.detail-heading` and its rows or chips. A row is a block with text and a `.detail-row-meta`. When the whole row is the target it is an `a` or a `button` with the same classes, which gets the pointer and an accent colour on hover (the first row). When only a word in it is, that word is an `a` inside the row (the last two sections). A chip is a `span`, an `a` or a `button`, and only the last two are drawn as pressable. There is no `.detail-row` component in React, because the rows are the app's content and one line of markup each.
|
|
1015
|
+
- Long words and unbroken strings wrap inside the panel and never widen it.
|
|
1016
|
+
|
|
1017
|
+
#### List row and day separator
|
|
1018
|
+
|
|
1019
|
+
Rows grouped by day, as the calls drawer and the conversation list show them. The first row is the current one, and it carries a time (see "Relative time").
|
|
1020
|
+
|
|
1021
|
+
```html
|
|
1022
|
+
<div>
|
|
1023
|
+
<h3 class="day-sep">Today</h3>
|
|
1024
|
+
<a class="list-row" href="#" aria-current="page">
|
|
1025
|
+
<span class="list-row-title">Waterfield demo call</span>
|
|
1026
|
+
<span class="list-row-meta"><time class="fk-time" datetime="2026-09-30T08:16:00Z" title="30 Sep, 08:16">4 h ago</time> · 119 entities</span>
|
|
1027
|
+
</a>
|
|
1028
|
+
<h3 class="day-sep">22 Sep</h3>
|
|
1029
|
+
<button class="list-row" type="button">
|
|
1030
|
+
<span class="list-row-title">Daily catchup, Fayaz and Rana</span>
|
|
1031
|
+
<span class="list-row-meta">86 s · 5 entities</span>
|
|
1032
|
+
</button>
|
|
1033
|
+
<h3 class="day-sep">12 Sep · imported</h3>
|
|
1034
|
+
<a class="list-row" href="#">
|
|
1035
|
+
<span class="list-row-title">September operating checkpoint</span>
|
|
1036
|
+
<span class="list-row-meta">MS Teams · 22 entities</span>
|
|
1037
|
+
</a>
|
|
1038
|
+
</div>
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
- A row is a 13px title over a 12px muted line, both cut with an ellipsis and never wrapping. It is an `a` when it goes to an address and a `button` when it selects, and the classes reset both to the same look. Hover is the muted fill at 60%.
|
|
1042
|
+
- The row the page is on has `aria-current`, `"page"` on a link and `"true"` on a button, and that attribute is the only thing that draws it, as `aria-selected` draws `.search-row`. A class cannot stand in for it.
|
|
1043
|
+
- The current row's fill is the accent colour at 70%. The mockup used the primary colour at 8%, which gives the 12px meta line 4.31:1 in light, and the accent at 70% gives it 4.56:1 and stays blue. The contrast test holds both the meta line and the title on it.
|
|
1044
|
+
- `.day-sep` is an 11px muted label with no margin of its own, an `h3` so that the groups can be jumped between. Put it under the heading of the list. It is a label and nothing more. The page decides what the groups are ("Today", "22 Sep", "12 Sep · imported") and in what order, because chat groups by "Previous 7 days" and the explorer by date, and one bucketing function would suit neither.
|
|
1045
|
+
|
|
1046
|
+
#### Status badges
|
|
1047
|
+
|
|
1048
|
+
A dot in the status colour and the status as a plain word.
|
|
1049
|
+
|
|
1050
|
+
```html
|
|
1051
|
+
<span class="badge b-success">Ready</span>
|
|
1052
|
+
<span class="badge b-warning">Rebuilding</span>
|
|
1053
|
+
<span class="badge b-warning">Interrupted</span>
|
|
1054
|
+
<span class="badge b-destructive">Failed</span>
|
|
1055
|
+
<span class="badge b-muted">Skipped</span>
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
| Status | Class | Words |
|
|
1059
|
+
|---|---|---|
|
|
1060
|
+
| Success | `.b-success` | Ready, Connected |
|
|
1061
|
+
| Warning | `.b-warning` | Rebuilding, Interrupted, Pending |
|
|
1062
|
+
| Destructive | `.b-destructive` | Failed, Action needed |
|
|
1063
|
+
| Muted | `.b-muted` | Skipped, Not connected |
|
|
1064
|
+
|
|
1065
|
+
- The class is the colour and the word is the meaning, so the word is never left out. Which word goes with which status is the surface's, and the table is what the rebuild history and platform-ui's `StatusDot` would use.
|
|
1066
|
+
- `.badge::before` is already a dot in the text colour, and each status class sets the dot's colour to `--success`, `--warning`, `--destructive` or `--muted-foreground`. The badge has no fill and no padding, so it lines up with the text around it.
|
|
1067
|
+
- The word is in the text colour and not the role colour. The signal badges (`.b-risk`, `.b-action`, `.b-incident`) draw the role colour on a 13% tint, which is 2.5:1 for `--warning` in light, 3.2:1 for `--success` and 4.2:1 for `--destructive`, so none of them reaches 4.5:1 for small text. They are unchanged, and are a different kind of badge, tinted signal types. A status is a different kind of thing and does not reuse them.
|
|
1068
|
+
- Contrast. The word is held to 4.5:1 on the card, the popover, the page and the muted fill in both themes. The dots are held to the 3:1 of graphics, with one exception. `--warning` in light is 2.87:1 on the card and 2.74:1 on the page, so the warning dot is under 3:1 there. The dot repeats what the word says, so nothing depends on it, and lifting it means changing a token that every warning uses, which 1.3.0 does not do. The test holds it at 2.7:1, just under the measured 2.74:1 on the page, so that it cannot fall further unseen, and the numbers are printed when `npm test` runs. Changing the light `--warning` role is the fix if this is not acceptable.
|
|
1069
|
+
|
|
1070
|
+
#### Relative time
|
|
1071
|
+
|
|
1072
|
+
A moment as a short phrase, with the exact time on hover.
|
|
1073
|
+
|
|
1074
|
+
```html
|
|
1075
|
+
<time class="fk-time" datetime="2026-09-30T12:16:00Z" title="30 Sep, 12:16">just now</time>
|
|
1076
|
+
<time class="fk-time" datetime="2026-09-30T12:04:00Z" title="30 Sep, 12:04">12 min ago</time>
|
|
1077
|
+
<time class="fk-time" datetime="2026-09-30T08:16:00Z" title="30 Sep, 08:16">4 h ago</time>
|
|
1078
|
+
<time class="fk-time" datetime="2026-09-22T08:16:00Z" title="22 Sep, 08:16">22 Sep, 08:16</time>
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
The wording is one function, and every surface uses it.
|
|
1082
|
+
|
|
1083
|
+
| How long ago | Text | Example |
|
|
1084
|
+
|---|---|---|
|
|
1085
|
+
| Under 1 minute | `just now` | just now |
|
|
1086
|
+
| 1 to 59 minutes | minutes, `min ago`, rounded down | 12 min ago |
|
|
1087
|
+
| 1 to 23 hours | hours, `h ago`, rounded down | 4 h ago |
|
|
1088
|
+
| 1 day or more | the exact time | 22 Sep, 08:16 |
|
|
1089
|
+
| 1 year or more (365 days) | the exact time with the year | 22 Sep 2025, 08:16 |
|
|
1090
|
+
| Up to 1 minute ahead | `just now` | just now |
|
|
1091
|
+
| More than 1 minute ahead | the exact time, with the year from 365 days away | 30 Sep, 16:20 |
|
|
1092
|
+
|
|
1093
|
+
- The exact time is day, then the month name, then the time on a 24 hour clock, in the reader's time zone. The order and the month names are fixed and do not follow the browser's locale, because the ticket's own example is "22 Sep, 08:16" and one wording is the point. Month names are written out in the code because the platform gives "Sept" for September in en-GB. Seconds are never shown.
|
|
1094
|
+
- A moment a few seconds ahead is "just now", since a clock a little fast is common. A moment further ahead is a date and not an age, so it is written exactly and never as "in 4 h". A year is 365 days, which is when a day and a month stop being unambiguous. A value that is not a date gives an empty string, and `RelativeTime` renders nothing for it.
|
|
1095
|
+
- The exact time is in the `title` attribute, and not in `.fk-tooltip`. `.fk-tooltip` is a Radix `Content` class with no script behind it on a plain page (the script builds no tooltip), so a title is the one choice that is the same in HTML and in React, needs no provider, and costs nothing in a long list. chat's message time already does this. A title does not show on touch screens and is not reached by the keyboard, and the datetime attribute carries the exact value for anything that needs it. Where the exact time matters more than that, put it in the text.
|
|
1096
|
+
- The text is written by the page. Give the `<time>` its `datetime`, the relative text and the `title`. It does not refresh on its own on a plain page, and the page writes it again when it re-renders. `RelativeTime` in React does refresh, see below.
|
|
1097
|
+
|
|
1098
|
+
The formatter, for a page that is not React. `shell.js` exposes it, so a page has one copy and does not carry its own.
|
|
1099
|
+
|
|
1100
|
+
```html
|
|
1101
|
+
<script src="/static/fikar/shell.js"></script>
|
|
1102
|
+
<script>
|
|
1103
|
+
var when = new Date(call.created_at);
|
|
1104
|
+
time.setAttribute('datetime', when.toISOString());
|
|
1105
|
+
time.textContent = FikarShell.formatRelative(when);
|
|
1106
|
+
time.title = FikarShell.formatExact(when);
|
|
1107
|
+
</script>
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
`FikarShell.formatRelative(value, now)` and `FikarShell.formatExact(value, now)` take a Date or an ISO string, and `now` (a Date) defaults to the current time and is for tests. There is no `data-fk-time` attribute and no timer in `shell.js`, because the only plain page that needs this, the graph explorer, builds its rows from script and has no need of one. A page that has to refresh, or a server rendered page that wants the text filled in, is a reason to add that later, and it would be additive.
|
|
1111
|
+
|
|
1112
|
+
The wording exists twice, in `shell.js` (a classic script) and in `@fikar-ai/design-react` (`format-time.ts`), because the React package cannot import a classic script and does not depend on `@fikar-ai/design`. Both are tested against `test/fixtures/time-rules.json`, every boundary in the table above, and the React test also runs `shell.js` itself over a spread of ages and compares the results, so the two cannot drift apart unnoticed.
|
|
1113
|
+
|
|
1114
|
+
#### React components
|
|
1115
|
+
|
|
1116
|
+
```tsx
|
|
1117
|
+
import { DaySeparator, DetailPanel, DetailSection, ListRow, RelativeTime, StatusBadge, formatExact, formatRelative } from '@fikar-ai/design-react';
|
|
1118
|
+
|
|
1119
|
+
<DetailPanel type="Person" dot="PERSON" title="John" description={node.description} onClose={() => select(null)}>
|
|
1120
|
+
<DetailSection heading="Strongest connections">
|
|
1121
|
+
{links.map((l) => (
|
|
1122
|
+
<div className="detail-row" key={l.id}><span>{l.name}</span><span className="detail-row-meta">{l.summary}</span></div>
|
|
1123
|
+
))}
|
|
1124
|
+
</DetailSection>
|
|
1125
|
+
</DetailPanel>
|
|
1126
|
+
|
|
1127
|
+
<DaySeparator label="Today" />
|
|
1128
|
+
<ListRow component={Link} to={`/calls/${call.id}`} active={call.id === currentId} title={call.title} meta={<><RelativeTime date={call.startedAt} /> · {call.entities} entities</>} />
|
|
1129
|
+
|
|
1130
|
+
<StatusBadge status="warning">Rebuilding</StatusBadge>
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
| Component | Props |
|
|
1134
|
+
|---|---|
|
|
1135
|
+
| `DetailPanel` | `type` (required, the type line's words), `dot` (an entity type, `'PERSON'`, `'ORGANIZATION'`, `'EVENT'` or `'GEO'`, otherwise the dot is muted), `title` (required), `description`, `onClose` (renders the close button, and none without it), `children`. |
|
|
1136
|
+
| `DetailSection` | `heading` and `children`. |
|
|
1137
|
+
| `ListRow` | `title` (required), `meta`, `active`, `component`, and every other prop goes to the element. |
|
|
1138
|
+
| `DaySeparator` | `label`. |
|
|
1139
|
+
| `StatusBadge` | `status` (`'success'`, `'warning'`, `'destructive'` or `'muted'`) and `children` for the word. |
|
|
1140
|
+
| `RelativeTime` | `date` (a Date or an ISO string), and `now` for tests. |
|
|
1141
|
+
| `formatRelative`, `formatExact` | `(value, now?)`, the same wording as `shell.js`. |
|
|
1142
|
+
|
|
1143
|
+
- None of them takes a `className`, as with the other components (KAN-311). `ListRow` and `DaySeparator` are plain elements, and `RelativeTime` and `StatusBadge` render one element each.
|
|
1144
|
+
- `ListRow` is a `button` by default, and `onClick` is the handler. The ticket called it `onSelect`, but that is also the name of a DOM event that reports a text selection, and the row would then have two meanings for it. For a link pass `component="a"` with `href`, or the router's `Link` with `to`, as `NavItem` does. `active` sets `aria-current` to `"page"` on anything that is not a button and to `"true"` on a button. Pass the router's `Link` and not `NavLink`.
|
|
1145
|
+
- `DetailPanel` names itself from its title with a generated id, so two panels on a page do not clash. The rows and chips inside a `DetailSection` are the markup above, written with `className`.
|
|
1146
|
+
- `RelativeTime` refreshes itself while it is mounted, every 30 seconds while the moment is under an hour away, every 5 minutes under a day, and never once the text is an exact time. With `now` given it never refreshes. The exact time is a `title` and not a Radix tooltip.
|
|
1147
|
+
|
|
1148
|
+
#### Adopting them
|
|
1149
|
+
|
|
1150
|
+
Move `@fikar-ai/design` and `@fikar-ai/design-react` to 1.3.0 together, and merge nothing that adds a local copy of these before that.
|
|
1151
|
+
|
|
1152
|
+
- chat replaces the hand styled Sources panel (`chat-right-sidebar.tsx`, `entity-passages.tsx`) with `DetailPanel` and `DetailSection`. The heading "Sources" and the back and hide buttons stay in the layout, and the entity view becomes the panel with `dot={entity.type}`. The thread list uses `DaySeparator` and `ListRow` (with the router `Link` as `component`, `active` from the route) and keeps its `groupByDate` for the groups, with its rename and delete menu beside the row. Message times and the activity times use `RelativeTime` or `formatExact` in place of the local `formatTime` and `formatRelativeTime` and their `Intl.RelativeTimeFormat`, which say "4 hours ago" and use en-US.
|
|
1153
|
+
- platform-ui replaces `StatusDot` in the integrations card and detail page with `StatusBadge` (`connected` is `success`, `error` is `destructive`, `pending` is `warning` or `muted` as the page decides, `disconnected` is `muted`), and replaces `formatRelativeTime` in the activity feed, the PII table and the integration card with `RelativeTime`. Its `formatDate` calls stay, since they are dates and not ages.
|
|
1154
|
+
- The crs explorer copies the four recipes from its vendored package and loads `shell.js`. It writes each node panel with `.detail-panel` inside its own absolutely placed wrapper (which gives way to the full width under 768px), the calls drawer with `.list-row` and `.day-sep`, the rebuild history with `.badge .b-*`, and every time with `FikarShell.formatRelative` and `FikarShell.formatExact`, so it carries no formatter of its own. It must import `fonts.css` for the serif title.
|
|
1155
|
+
|
|
1156
|
+
### Empty state recipe
|
|
1157
|
+
|
|
1158
|
+
Markup: `recipes/empty-state.html` (`@fikar-ai/design/recipes/empty-state.html`). One mark, a heading,
|
|
1159
|
+
one line of orientation and at most one action.
|
|
1160
|
+
|
|
1161
|
+
## Rule for agents and people writing UI
|
|
1162
|
+
|
|
1163
|
+
If it appears on more than one surface, it lives in the design packages. If it only exists here, it lives here.
|
|
1164
|
+
1. Is this chrome? Shell, header, sidebar, launcher, theme menu, user menu, search box, avatar, tooltip, button, empty state, loader: use the shared one. Never create or copy one locally.
|
|
1165
|
+
2. Does the package lack what I need? Change the design repo first, publish, then use it here. Do not patch around it in the app.
|
|
1166
|
+
3. Am I changing a colour, spacing, font or breakpoint? That is a token. Change it in the package or use an existing one. No hard-coded values in app code.
|
|
1167
|
+
4. Is this repo behind on the design version? Merge the pending update pull request before starting work on chrome.
|
|
1168
|
+
5. Is it content? Nav items, page layouts, domain components, data: build it here. Do not move it to the package until a second surface needs it.
|
|
1169
|
+
|
|
1170
|
+
## Use it — Tailwind + shadcn apps (admin, platform, chat, cost, marketing)
|
|
1171
|
+
|
|
1172
|
+
```ts
|
|
1173
|
+
// tailwind.config.ts
|
|
1174
|
+
import fikarPreset from '@fikar-ai/design/preset';
|
|
1175
|
+
import animate from 'tailwindcss-animate';
|
|
1176
|
+
|
|
1177
|
+
export default {
|
|
1178
|
+
presets: [fikarPreset], // colors / fonts / radius / keyframes come from here now
|
|
1179
|
+
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
1180
|
+
plugins: [animate], // keep the plugin local
|
|
1181
|
+
// delete your own theme.extend.colors / fontFamily / borderRadius / keyframes
|
|
1182
|
+
};
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
```css
|
|
1186
|
+
/* src/styles/globals.css */
|
|
1187
|
+
@import '@fikar-ai/design/tokens.css'; /* replaces your local :root / .dark blocks */
|
|
1188
|
+
@tailwind base;
|
|
1189
|
+
@tailwind components;
|
|
1190
|
+
@tailwind utilities;
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
The app shell (sidebar, top bar, launcher, collapse rule) comes from this package too; see the app shell recipe above.
|
|
1194
|
+
React apps adopt it through `@fikar-ai/design-react` (see "React apps" below) rather than hand-rolling the markup.
|
|
1195
|
+
Anything else uses the classes and the fixture in `recipes/shell.html` as they are and does not fork them.
|
|
1196
|
+
|
|
1197
|
+
Then delete the now-duplicated `:root`/`.dark` variable blocks and the duplicated Tailwind theme config. Ensure **Inter + JetBrains Mono** are loaded in `index.html` (chat currently loads Fraunces/Hanken — swap those).
|
|
1198
|
+
|
|
1199
|
+
## React apps: @fikar-ai/design-react
|
|
1200
|
+
|
|
1201
|
+
React apps get the app shell as a component instead of copying the recipe markup. The package renders
|
|
1202
|
+
`recipes/shell.html` exactly, owns the drawer and the desktop collapse, and shows Radix tooltips in the rail.
|
|
1203
|
+
It ships no CSS and takes no Tailwind dependency; every class it uses is in `components.css`. It releases at
|
|
1204
|
+
the same version as `@fikar-ai/design`, so install matching versions.
|
|
1205
|
+
|
|
1206
|
+
```sh
|
|
1207
|
+
npm install @fikar-ai/design-react @fikar-ai/design @radix-ui/react-tooltip react react-dom
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
```css
|
|
1211
|
+
/* src/styles/globals.css, before the @tailwind directives */
|
|
1212
|
+
@import '@fikar-ai/design/tokens.css';
|
|
1213
|
+
@import '@fikar-ai/design/components.css';
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
A sidebar that is a list of links. `NavItem` takes the router's link as `component`, and the shell shows the
|
|
1217
|
+
same items open and in the rail:
|
|
1218
|
+
|
|
1219
|
+
```tsx
|
|
1220
|
+
import { AppShell, NavItem } from '@fikar-ai/design-react';
|
|
1221
|
+
import logo from '@fikar-ai/design/brand/fikar-logo.svg';
|
|
1222
|
+
import { Link, Outlet, useLocation } from 'react-router-dom';
|
|
1223
|
+
|
|
1224
|
+
export function Layout() {
|
|
1225
|
+
const { pathname } = useLocation();
|
|
1226
|
+
return (
|
|
1227
|
+
<AppShell
|
|
1228
|
+
brand={<img src={logo} alt="" />}
|
|
1229
|
+
brandName="FIKAR"
|
|
1230
|
+
nav={
|
|
1231
|
+
<>
|
|
1232
|
+
<NavItem component={Link} to="/" icon={<HomeIcon />} label="Home" active={pathname === '/'} />
|
|
1233
|
+
<NavItem component={Link} to="/settings" icon={<GearIcon />} label="Settings" active={pathname === '/settings'} />
|
|
1234
|
+
</>
|
|
1235
|
+
}
|
|
1236
|
+
footer={<AccountBlock />}
|
|
1237
|
+
headerActions={<><Launcher /><ThemeToggle /><UserMenu /></>}
|
|
1238
|
+
>
|
|
1239
|
+
<Outlet />
|
|
1240
|
+
</AppShell>
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
A sidebar that is not a list of links, such as a conversation list with a search box. `nav` is the open
|
|
1246
|
+
rendering. `navRail` is what the 72px rail shows; leave it out and the rail shows `nav` with its `.nav-label`
|
|
1247
|
+
text clipped by the CSS:
|
|
1248
|
+
|
|
1249
|
+
```tsx
|
|
1250
|
+
<AppShell
|
|
1251
|
+
brand={<img src={logo} alt="" />}
|
|
1252
|
+
brandName="FIKAR"
|
|
1253
|
+
navLabel="Conversations"
|
|
1254
|
+
nav={
|
|
1255
|
+
<>
|
|
1256
|
+
<NewConversationButton />
|
|
1257
|
+
<SearchBox />
|
|
1258
|
+
<ThreadList />
|
|
1259
|
+
</>
|
|
1260
|
+
}
|
|
1261
|
+
navRail={<NewConversationIconButton />}
|
|
1262
|
+
footer={<AccountBlock />}
|
|
1263
|
+
headerActions={<><Launcher /><ThemeToggle /><UserMenu /></>}
|
|
1264
|
+
>
|
|
1265
|
+
<Outlet />
|
|
1266
|
+
</AppShell>
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
Optional pieces, all opt-in and matching the "Optional pieces" section above:
|
|
1270
|
+
|
|
1271
|
+
- `headerStart` renders `.shell-topbar-start` to the left of the cluster, only when given.
|
|
1272
|
+
- `navScroll` adds `is-scroll` to the nav, for long lists such as a conversation list.
|
|
1273
|
+
- `NavHeading` renders a group heading between nav items. `NavItem` takes `badge` for the pill.
|
|
1274
|
+
- `NavItem variant="back"` renders the quieter back link (`nav-item-back`), for the way out to the app the person came from.
|
|
1275
|
+
- `BrandLogo` renders both logo variants with the classes that show one, for the `brand` slot:
|
|
1276
|
+
`<BrandLogo lightSrc={logoLight} darkSrc={logoDark} alt="" height={18} />`. `alt` defaults to an empty string.
|
|
1277
|
+
- `fill` makes the shell one viewport tall with a padless content column, for a page that scrolls inside itself.
|
|
1278
|
+
- `sidebar={false}` renders the top bar shell with no sidebar (see "No-sidebar mode"). `nav`, `navRail`, `navScroll`,
|
|
1279
|
+
`footer`, `navLabel` and the collapse props are `never` in that mode, so passing one is a type error. It reads and writes
|
|
1280
|
+
no storage, renders no drawer, and `useAppShell()` returns `collapsed: false`, `drawerOpen: false`, `isRail: false`
|
|
1281
|
+
with setters that do nothing. `sidebar` is a literal, not a computed boolean, because it selects the prop set.
|
|
1282
|
+
- `AppShell` renders `.side-foot` only when `footer` is given.
|
|
1283
|
+
|
|
1284
|
+
`NavItem` and routers: pass the router's `Link` (not `NavLink`) as `component`, and compute `active` in the app.
|
|
1285
|
+
NavLink manages its own class and `aria-current`, which fights the `active` prop and doubles the class.
|
|
1286
|
+
`NavItem` warns in development when it is handed a `NavLink`.
|
|
1287
|
+
|
|
1288
|
+
Also worth knowing: `brandName` is a separate prop because the wordmark must sit inside `.brand` for the rail to
|
|
1289
|
+
hide it. `isRail` from `useAppShell()` is true only while the rail is showing (collapsed and at least 1024px wide),
|
|
1290
|
+
for slot content that changes shape there. A click on a `NavItem` closes the drawer, so a tapped link does not leave
|
|
1291
|
+
it covering the new page, unless the click handler calls `preventDefault`.
|
|
1292
|
+
|
|
1293
|
+
Rules the component enforces: it renders plain string class names only, so a router link never receives a
|
|
1294
|
+
function `className` through Radix (KAN-311); the collapsed choice is read after the first render, so
|
|
1295
|
+
server-rendered markup matches; and storage that is blocked only means the choice is not remembered. Content
|
|
1296
|
+
inside the shell can read and drive the state with `useAppShell()`, which returns `collapsed`,
|
|
1297
|
+
`setCollapsed`, `drawerOpen`, `setDrawerOpen` and `isRail`. To own the collapsed state yourself, pass
|
|
1298
|
+
`collapsed` and `onCollapsedChange`; the shell then does not touch storage.
|
|
1299
|
+
|
|
1300
|
+
## Use it — Docusaurus (docs-site)
|
|
1301
|
+
|
|
1302
|
+
Docs imports only the raw ramp and aliases Infima to it. Do **not** delete the derived Infima primary shades or the `--ifm-*` mappings — those are Infima-specific and have no other consumer.
|
|
1303
|
+
|
|
1304
|
+
```css
|
|
1305
|
+
/* src/css/custom.css */
|
|
1306
|
+
@import '@fikar-ai/design/tokens.css'; /* delete the hand-copied --fk-* ramp block; keep your --ifm-* aliases */
|
|
1307
|
+
/* :root { --ifm-color-primary: hsl(var(--fk-blue600)); ... } ← these keep working unchanged */
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
Docusaurus toggles `[data-theme='dark']`, not `.dark`, so docs keeps its own dark block (it already references `--fk-*`).
|
|
1311
|
+
|
|
1312
|
+
## Tokens
|
|
1313
|
+
|
|
1314
|
+
- Ramp is `--fk-*` (e.g. `--fk-blue600`, `--fk-n200`). Compose with `hsl()`: `hsl(var(--fk-blue600) / 0.5)`.
|
|
1315
|
+
- Semantic roles are unprefixed (`--primary`, `--background`, …) per the shadcn convention; they resolve through the ramp.
|
|
1316
|
+
|
|
1317
|
+
## Quality gates (`npm test`, also CI publish gate)
|
|
1318
|
+
|
|
1319
|
+
- **parity** — `tokens.css` must reproduce every value in the canonical `docs/mockups/fikar.css` (under the `--fk-` rename). A fat-fingered value fails here.
|
|
1320
|
+
- **shell and recipes**, the fixtures use only classes that exist, the collapse rules stay inside the 1024px query, and every 0.4.0 shell rule is unchanged.
|
|
1321
|
+
- **shell script**, `shell.js` runs against a fake DOM: collapse, persistence, throwing storage, drawer, no double bind.
|
|
1322
|
+
- **macros**, the Jinja macros render the fixtures (skipped when `python3` with `jinja2` is missing).
|
|
1323
|
+
- **shell pieces**, the opt-in tooltip, start slot, heading, badge and scrolling nav exist, use tokens only and stay out of the rail; the macros render `test/fixtures/shell-extras.html`.
|
|
1324
|
+
- **0.8 shell**, the closed drawer leaves the tab order, the footer rule matches direct children only, the logo, rail, fill,
|
|
1325
|
+
back link and no-sidebar rules exist with the right scoping, and the fixtures for each parse; the macros render every fixture
|
|
1326
|
+
in `test/fixtures/` and `recipes/`.
|
|
1327
|
+
- **launcher and theme menu**, the launcher macro still renders the exact 1.1.0 markup and the opt in adds only `data-fk-menu`, the theme CSS swaps the icon from the root theme with no script, the recipe has the documented structure, `shell.js` runs the radio rows and sends the event against the fake DOM, and the new pairings are in the contrast test. The macros, `AppLauncher` and `ThemeMenu` render the recipes.
|
|
1328
|
+
- **user menu**, the classes exist, use tokens only and leave `.avatar` and the launcher rules unchanged, the recipe has the documented structure and sits in both shell recipes, and `shell.js` menu behaviour runs against the fake DOM. The macros and `UserMenu` render `recipes/user-menu.html`.
|
|
1329
|
+
- **search**, the search classes exist, use tokens only and leave the three 1.2.1 `.search` rules and the `.pill` rule unchanged, the recipes have the documented structure, `shell.js` focuses the registered box, leaves `/` alone while typing and clears on `.search-clear` against the fake DOM, and the entity roles keep chat's hues. The recipes, `SearchBox`, `SearchResults`, `FilterChip` and `EntityBadge` are compared by the parity tests, and the drift rule in this README is run against sample files.
|
|
1330
|
+
- **detail panel, list row, status badge and time**, the classes exist, use tokens only and leave placement to the page, the recipes have the documented structure, the formatters in `shell.js` and `@fikar-ai/design-react` are run against the same table of boundaries, the markup in the README is the recipes' byte for byte, and the status and list pairings are in the contrast test. `DetailPanel`, `ListRow`, `DaySeparator`, `StatusBadge` and `RelativeTime` are compared with the recipes by the parity tests.
|
|
1331
|
+
- **contrast** — core reading pairings meet WCAG-AA in both themes. (Muted text: 4.60:1 light / 6.85:1 dark.) The entity badges on their tint, the search text on its translucent fills, the status word and the list row text on their fills are held to 4.5:1. The status dots are held to 3:1, except the light warning dot, which is held at 2.7:1, just under its measured 2.74:1 on the page.
|
|
1332
|
+
|
|
1333
|
+
> No build step: `tokens.css` is the source, `preset.js` ships as-is. No snapshot test — `git diff` on a hand-written token file already shows every value change.
|
|
1334
|
+
|
|
1335
|
+
## Versioning
|
|
1336
|
+
|
|
1337
|
+
`@fikar-ai/design` and `@fikar-ai/design-react` follow semantic versioning from 1.0.0. Both packages always release together at one version, so a release of either is a release of both, and the React package's peer range on `@fikar-ai/design` is `>=1.0.0 <2`.
|
|
1338
|
+
|
|
1339
|
+
The public surface is what a version number promises about:
|
|
1340
|
+
|
|
1341
|
+
- Class names and the markup of the recipes.
|
|
1342
|
+
- The CSS custom properties in `tokens.css`.
|
|
1343
|
+
- The Tailwind preset's keys.
|
|
1344
|
+
- `shell.js` behaviour, its global `FikarShell.init`, the `data-fk-menu` and `data-fk-search` attributes, the `fk-theme-change` event, and the storage key (`fikar.shell.collapsed`) and its values (`"1"` collapsed, `"0"` open).
|
|
1345
|
+
- The Jinja macro names and parameters.
|
|
1346
|
+
- The React exports and their props.
|
|
1347
|
+
- The files and export paths of both packages.
|
|
1348
|
+
|
|
1349
|
+
Anything else, such as the internals of `recipes/demo.html`, the tests, or the private modules behind the React exports, is not covered and can change in any release.
|
|
1350
|
+
|
|
1351
|
+
| Bump | What goes in it |
|
|
1352
|
+
|---|---|
|
|
1353
|
+
| Major | A change that can break a consumer that upgrades without editing anything. Removing or renaming anything on the public surface, changing the rendered result of existing markup, tightening a peer range. |
|
|
1354
|
+
| Minor | Additions that are opt-in. New classes, new props, new macro parameters with defaults, new files. |
|
|
1355
|
+
| Patch | Fixes that restore documented behaviour, and documentation. |
|
|
1356
|
+
|
|
1357
|
+
A behaviour change to existing markup is a major change, even when it is small and deliberate. Before 1.0.0 four such changes shipped in 0.8.0 and are listed in "Upgrading from 0.7". From now on each one waits for the next major release, and that release's notes list it with the migration.
|
|
1358
|
+
|
|
1359
|
+
Consumers can use a caret range (`^1.0.0`) for both packages and get minors and patches without editing anything.
|
|
1360
|
+
|
|
1361
|
+
A release is a `v*` tag push. If a second run starts for the same tag, it waits for the first, then skips any package that is already on npm at that version and publishes only what is missing. The registry can take about two minutes to show a new version, so if a publish fails the run polls for up to four minutes and passes when the version appears. A run that cannot reach the registry, or whose version never appears, fails.
|
|
1362
|
+
|
|
1363
|
+
## Retiring something
|
|
1364
|
+
|
|
1365
|
+
An item on the public surface is retired in two steps.
|
|
1366
|
+
|
|
1367
|
+
1. It is marked deprecated in a minor release. The README says what replaces it. For a React export or prop the source carries a `@deprecated` JSDoc tag, and using it logs one console warning per session, in development builds only.
|
|
1368
|
+
2. It is removed no earlier than the next major release, and that release's notes say how to migrate.
|
|
1369
|
+
|
|
1370
|
+
Nothing is deprecated in 1.0.0.
|
|
1371
|
+
|
|
1372
|
+
## Upgrading from 0.8
|
|
1373
|
+
|
|
1374
|
+
1.0.0 changes no behaviour. No CSS rule, markup, script logic, React component logic or macro output differs from 0.8.0. The release only changes the version number and states what it promises (see "Versioning"). What to do, by consumer:
|
|
1375
|
+
|
|
1376
|
+
- Account console and graph explorer (vendored CSS, `shell.js` and Jinja macros): nothing. Copies taken from 0.8.0 are identical to 1.0.0. If you record the vendored version, update it.
|
|
1377
|
+
- React apps (`AppShell`): move `@fikar-ai/design-react` and `@fikar-ai/design` to 1.0.0 in the same change. The peer range on `@fikar-ai/design` is now `>=1.0.0 <2`, so a 0.x `@fikar-ai/design` no longer satisfies it. `react`, `react-dom` and `@radix-ui/react-tooltip` ranges are unchanged.
|
|
1378
|
+
- Tailwind and token-only apps (preset, `tokens.css`, docs site): move `@fikar-ai/design` to 1.0.0. A caret range is safe from here on.
|
|
1379
|
+
- Anything that skipped the cleanups in "Upgrading from 0.7" (wrapper spans around logo variants, forced footer colours, local hide-in-rail, fill and back-link patches, a hand-written top bar) can still make them. They are optional.
|