Where it is going
The product interface is dark throughout today. It becomes a light work area with a dark sidebar as its anchor: quiet, high in contrast, generous with white space, thin on ornament.
Two sources give it character, and neither is decoration. The low-poly logo supplies a vocabulary of shapes nobody else has: facets and cut corners. The data location is the argument that sells the product, so it works in the interface rather than sitting in the imprint.
This part covers the product interface only. Logo, colour, type, product names and voice are the same brand and live in the Frontend part — sections 02, 03, 04, 05 and 07. Nothing there is repeated here, because a rule written twice is a rule that will eventually disagree with itself.
- Light work area, dark sidebar. Both on the same hue axis, so the seam reads as a fold rather than a change of material.
- The butterfly appears once, on the login, in a panel. Not on every screen.
- Gold means AI. Nothing else, anywhere. Section 05.
Overview
EU regionWhat is binding
Three colours and four rules. Everything else in this part is a proposal that a better idea may replace.
The three colours
All three come from the brand palette in Frontend section 03. The relaunch brief proposed #8151D6 and #EE9E27, a hair away from the brand values. Two violets that differ by a hair are the failure the guide names first, so the brand values win.
:root{
/* product action: primary button, active nav item, filled states */
--violet: #8250E6; /* white on it: 4.95:1 */
--violet-deep: #432B78; /* hover on filled violet, and violet TYPE on pale */
--violet-lift: #B49BF0; /* focus ring on the dark sidebar */
/* AI, and only AI */
--gold: #EDA938;
--gold-ink: #4A3006; /* text on gold: 6.02:1 */
}
- Gold is never a text colour.
#EDA938on white is 2.03:1. - Violet is never a text colour on the light ground either.
#8250E6on#F2F0F8is 4.39:1 and misses. Violet type on pale is#432B78at 10.02:1 — which is exactly what Frontend section 03 already says it is for. - Gold is not a warning, not an accent, not a "looks good here". The moment gold also means something else, it means nothing.
- Violet is not a text colour in the sidebar either.
#8250E6on--side-bgis 3.18:1 — enough for a fill or an indicator, not for a word. Sidebar text is--side-inkat 13.83:1 or--side-ink-2at 6.51:1.
The four rules
- Body and secondary text clear 4.5:1 against the palest ground it actually sits on, not against white. A grey that passes on a white card can miss on
--bg. When it gets tight it gets darker, never smaller. - Focus is visible everywhere focus can land.
- prefers-reduced-motion is respected.
- Sidebar and work area share one hue axis. The values are free, the shared axis is not: neutral near-black against near-white grey reads as two materials rather than one surface folded.
Tokens
Starting values, not scripture. Where the project already has a scale for radii, spacing or shadows, that scale wins.
:root{
/* violet — all four from the brand palette, no new values */
--violet:#8250E6; --violet-deep:#432B78; --violet-lift:#B49BF0;
--violet-wash:rgba(130,80,230,.10);
/* AI */
--gold:#EDA938; --gold-ink:#4A3006; --gold-wash:#FDF2DF; --gold-line:#E7A63F;
/* status — convention, not brand */
--ok:#1A7B4E; --ok-wash:#E3F4EC;
--warn:#B4500F; --warn-wash:#FCEEE6;
/* light work area */
--bg:#F2F0F8; --card:#FFFFFF; --card-line:rgba(21,19,30,.06);
--sunken:#F6F5FA; --line:#EBEAF2;
--ink:#141223; --ink-2:#5D5A72; --ink-3:#6C6A7C;
/* sidebar — same violet axis as --bg, see rule 4 */
--side-bg:#241E40; --side-line:rgba(255,255,255,.10);
--side-ink:#F1EFF9; --side-ink-2:#A9A2C8; --side-hover:rgba(255,255,255,.08);
/* login gradient, identical to the website */
--hero-1:#2C1B52; --hero-2:#1E1836; --hero-3:#141020;
/* form and space */
--radius:14px; --radius-sm:9px; --pad:22px;
--content-max:1180px; --read-max:720px;
--sidebar-w:246px; --sidebar-rail:74px;
}
Three values were corrected
Measured against rule 1, three numbers in the brief missed the bar the same brief sets. They are changed here, hue kept, darkened until they clear it.
| Token | Brief | Here | Why |
|---|---|---|---|
| --ink-3 | #8B879E · 3.47:1 | #6C6A7C · 4.66:1 | Secondary text missed 4.5:1 on white and on every other ground it sits on. |
| --ok | #1E8E5A · 4.14:1 | #1A7B4E · 4.62:1 | Same, for status text. Measured against --ok-wash, the tightest of its grounds. |
| --hero-* | #3a2c63 #241546 #0e0a1e | #2C1B52 #1E1836 #141020 | The brief called for the website gradient. These are the actual brand grounds. |
Both figures above are the tightest of three grounds: --card, --bg and the colour's own wash. Against white alone they read 5.26:1 and 5.27:1. --warn was checked the same way and passes as given, at 4.52:1 on --warn-wash.
The brief also states 8.4:1 for --gold-ink on gold. Recomputed it is 5.57:1 against the brief's gold and 6.02:1 against #EDA938. Both pass; the stated figure did not match either.
Into shadcn, not beside it
shadcn/ui already ships a token system. A second one next to it forces every component to answer which applies. These values go into the existing tokens.
| shadcn | becomes |
|---|---|
| --primary | --violet |
| --background | --bg |
| --card | --card |
| --muted | --sunken |
| --muted-foreground | --ink-2. The shadcn default is usually too light for 4.5:1 in a light theme. |
| --border | --line |
| --ring | --violet |
| --radius | --radius |
Gold, the sidebar colours and the hero gradient have no shadcn counterpart and stay separate tokens. Depending on version shadcn expects bare HSL triples without the hsl() wrapper — the hex values above have to be converted, not pasted.
--ink-2, 6.62:1
--ink-3, 5.26:1
brief #8B879E, 3.47:1
--ink-2, 5.86:1
--ink-3, 4.66:1
brief #8B879E, 3.07:1
--ok, 4.62:1
brief #1E8E5A, 3.63:1
Shell and layout
The frame every screen sits in: a dark sidebar, a light work area, and a limit on how wide a line of text may run.
| Measure | Value | Note |
|---|---|---|
| Sidebar | 246 px | Collapsed to a 74 px rail. The rail needs a logo variant without the wordmark. |
| Work area | max 1180 px | The value is a proposal. That there is a limit at all is the point. |
| Reading width | max 720 px | Chat messages and body copy, roughly 70 characters a line. |
| Radius | 14 px / 9 px | Cards and controls. |
- Type is Instrument Sans, self-hosted, as in Frontend section 04. The brief proposed Space Grotesk, Inter and JetBrains Mono; it also says the fonts are a suggestion and that a house typeface wins. The brand has one.
- No fonts from Google. The brief is right about this and the reason is worth repeating: the visitor's IP travels to Google, which the Munich court treated as a GDPR breach in 2022 (Az. 3 O 17493/20). For a product advertising GDPR compliance that is the most expensive mistake available.
- Load the logo locally. On external dependency for your own mark is unnecessary.
- Checked at 1280, 1440 and 1920, with the keyboard path visible the whole way.
Open and collapsed
The sidebar has exactly two widths. There is no third, and no drag handle.
| Open | Collapsed | |
|---|---|---|
| Width | 246 px | 74 px |
| Item | Icon 18 plus label | Icon 18, centred in a 44 px target |
| Label | Visible | Tooltip on hover and on focus, to the right |
| Logo | logo-small-dark.svg, mark and wordmark | icon-dark.svg, the butterfly alone |
| Coming soon | Grouped at the bottom under a heading | Group heading drops, the entries stay in order |
- The active item keeps its violet fill and its glow in both widths. Collapsing may not cost the answer to "where am I".
- The state is remembered per user, not per session and not per tab. Someone who collapsed it once did so on purpose.
- Collapsing is a real button with a label, reachable by keyboard, not a hover strip at the edge.
- The tooltip appears on focus, not only on hover — otherwise the collapsed sidebar is unusable from the keyboard, and a rail nobody can read is a rail nobody uses.
- Under 1024 px the rail is the default, under 768 it becomes a drawer — section 20.
- Never labels under the icons. That is a third width in disguise and it breaks at the first long word.
Assistant text stops here, at roughly 70 characters a line. At 1400 px without a limit the same paragraph runs past 140, which is the third observation in section 07.
The active entry keeps its fill and its glow. The wordmark becomes the butterfly. The label returns as a tooltip — on focus as well as on hover, or the rail is dead to the keyboard.
Marking AI
Gold marks AI and nothing else. A second meaning would cost the first one.
Colour alone is not enough. AI surfaces also carry a cut corner, so the marking survives where colour does not arrive: colour blindness, greyscale print, inverted display. The size of the bevel is free, its presence is not.
.facet{ clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px,
100% 100%, 12px 100%, 0 calc(100% - 12px)); }
The shape is not invented for the purpose. It is the cut corner of the low-poly butterfly, which is why the interface and the mark look related without the logo being pasted onto anything.
- Text on gold is
#4A3006, 6.02:1. Gold itself never carries text. - Where processing happens belongs to the model, not to a grey line under the composer. It is a selling point and a compliance statement, and it belongs where the decision is made.
- Gold as a warning colour. Warnings are
#B4500F. - Gold on a button that merely happens to be important.
Drafted in the studio. Review before publishing.
The colour is gone, the cut corner stays.
Butterfly in a panel
The login carries the butterfly. This is the one place where the implementation is fixed, because any deviation shows immediately.
No rebuild, no fork, and no re-configuration. Take butterfly-field.js from Frontend section 06 and embed it with the brand configuration, character for character:
<butterfly-field style="position:absolute;inset:0" gradient="#2C1B52,#1E1836,#141020" color-deep="#2C1B52" color-primary="#8250E6" color-lavender="#D8CBF2"></butterfly-field>
- No attribute is added and none is left out. The mark is one thing everywhere it appears. A butterfly that beats differently in the product than on the website is two marks.
- The panel behind it carries the same three colours in CSS. The element switches itself off under reduced motion, data saver, and on devices below 4 GB or four cores — it paints the gradient in WebGL, so without the CSS gradient the panel is then empty. This is not a change to the element; it is the surface it sits on.
What the relaunch brief asked for
The brief lists six differences from the website version. Measured against the shipped element, two need nothing and four are dropped.
| Asked for | Verdict | Why |
|---|---|---|
| Size from the container, not the window | already true | The element measures its own box in groesse(). Nothing to do. |
| No scroll effect | already true | The shipped file has no scroll listener at all. |
No mouse control (parallax="off") | dropped | It would make the product butterfly behave unlike the website one. The drift is part of how the mark reads. |
height="0.2" | dropped | Same reason. If the figure sits wrong, the panel is the wrong size — change the panel. |
CAM_Z 16 | dropped | A source change, and not exposed as an attribute. Forking the file to fit one panel is how two versions start. |
| Narrow mode at panel width | dropped | Also a source change. The shipped file halves the point count below 900 px window width and that is the behaviour the brand has. |
Nothing is free to tune. Strength, glow, scale, width, frame rate and resolution are part of the configuration above. The draft below runs the same file with the same attributes as Frontend section 06 — a flat violet panel there means the element switched itself off.
Sign in
Imprint · Privacy · Terms
What to fix
Observations from the interface as it stands. How each is solved is open; that each is a problem is not.
| What | Why it hurts |
|---|---|
| Two free-text fields stacked | The chat composer and the global AI bar sit on top of each other, both expecting natural language. Where the AI surface is the whole module, a second AI input above it has no job. Decide: the global bar disappears in the studio, or it merges with the composer. |
| The EU notice is the least important text on screen | It is a selling point and a compliance statement, set as a small grey line. It belongs on the model. |
| Line length is unbounded | At 1400 px assistant text runs past 140 characters a line. Readable is 60 to 80. |
| The thinking block is not readable | Light grey on dark, roughly 3:1, and always open. It is a detail you ask for, not default content. |
| Eight chats named “Ohne Titel” | The list stops working after a day of use. It needs a fallback that says something. The coloured dots beside each chat need a stated meaning or they can go. |
| Filters above zero posts | Furniture in an empty room. An empty state is an invitation to act — and after a move from another system the common case is import, not "write your first post". |
| The login puts the brand last | Google and Microsoft are solid white blocks; the own sign-in button is the quietest on the page. The fields barely have an edge, and there are two language switchers. |
| Navigation differs between screens | Campaigns and Members appear on one screenshot and not the other. Role, space, or a bug — to be established, not guessed. |
| Five of ten nav items say "Soon" | A product decision, not a design one. As it stands, every screen tells a trial customer that half the product is missing. |
| Flag emoji do not render on Windows | Letters appear instead of the flag. Frontend section 06 has 271 flags as SVG for exactly this reason. |
Reasoning
Collapsed by default. --ink-2 on --sunken is 6.11:1, against the roughly 3:1 it has today.
Density, spacing and type
One spacing scale, one type scale. Every value outside them is a special case that needs a reason.
Spacing
Base 4. Seven steps, nothing between them: 4 · 8 · 12 · 16 · 22 · 32 · 48. Card padding is 22, the gap between cards 14, the gap between groups 32.
Type in the interface
Instrument Sans throughout, as in Frontend section 04. The tracking follows the same rule: tighter as the size grows.
| Role | Size / weight | Tracking | Colour |
|---|---|---|---|
| Page title | 20 / 600 | −0.034em | --ink |
| Section title | 16 / 600 | −0.012em | --ink |
| Card title | 14 / 600 | 0 | --ink |
| Body | 13.5 / 400 | 0 | --ink |
| Secondary | 12.5 / 400 | 0 | --ink-2 |
| Eyebrow, table header | 10.5 / 600, uppercase | 0.14em | --ink-3 |
| Metric | 25 / 600, tabular | −0.034em | --ink |
- Figures use
font-variant-numeric: tabular-numsin tables, metrics and anywhere numbers sit above one another. Without it the digits have different widths and a column of figures visibly jitters between rows. - Row height 44 px, 36 in a compact table, never under 32.
- Icons: 16 in text and buttons, 18 in the sidebar, 20 in the topbar. One size per surface,
stroke-widthstays 2 — Frontend section 06. - Two elevation levels, not three. Cards sit on a 1 px border with no shadow. Overlays get
0 12px 40px rgba(20,18,35,.28). A shadow on a card that does not float is decoration. - Radius: 14 container, 9 control, 99 pill, 2 tag.
20 / 600 / −0.034em16 / 600 / −0.012em14 / 60013.5 / 40012.5 / 400 · --ink-210.5 / 600 / 0.14em25 / 600 · tabular-numsControls and their states
Every control has six states. One that is missing a state is not finished, and the one usually missing is loading.
| State | How |
|---|---|
| Rest | As specified. |
| Hover | Filled violet darkens to --violet-deep. Outlined and ghost get --violet-wash. |
| Active | No movement, no scale. The colour is enough. |
| Focus | 2 px --violet, offset 2. On the dark sidebar --violet-lift, 6.66:1. |
| Disabled | 40 % opacity, not a grey of its own. Not focusable. |
| Loading | The label stays, a spinner takes the leading icon's place, the width does not change. |
- One primary per view. Two violet buttons on one screen mean neither is the answer.
- The destructive colour is
#B3323A, and it does one job. White on it is 6.12:1; as type on light grounds it is 5.41:1 on--bg. Filled destructive only inside a confirm, outlined everywhere else. - Label is verb plus object. "Post löschen", never "Delete", never "OK". Section 19.
- Hit target 24 px minimum, 44 where touch is possible. Padding counts, the icon alone does not.
- A button that shrinks while loading moves everything after it. Reserve the width.
outline:nonewithout a replacement ring. Every removal of the ring is a keyboard user losing their place.- Disabling a control without saying why. If the reason matters, say it next to the control, not in a tooltip the keyboard cannot reach.
Forms
One column, label above the field, error beneath it. Anything else has to earn itself.
What a field is made of
| Part | Spec |
|---|---|
| Label | 12 / 600, --ink-2, above the field, always present. A placeholder is not a label — it disappears the moment someone types. |
| Field | 1 px --ink-3, radius 9, padding 10/12. A border you can see, not a hint of one. |
| Hint | 12, --ink-3, under the field, before any error. |
| Error | 12, #B3323A, with an icon, and the field border goes to 2 px in the same colour. |
| Optional | Marked in the label. Mark the minority: if most fields are required, mark the optional ones, and the other way round. Never both. |
- Validate on blur, not on keystroke. Once a field has errored, re-check it on every change so the error clears as soon as it is fixed.
- The error says what to do, not what happened. “Enter an email address with an @” beats “Invalid format”.
- Never disable submit to express validation. A disabled button gives no reason. Let it be pressed, then move focus to the first field that failed and name the count.
- Password managers have to work. A real
<form>,autocomplete="username"on the mail field,current-passwordornew-passwordon the password. A login built from divs is a login people abandon. - A form with unsaved changes warns before it is left, and the save bar states what is unsaved.
- Errors never rely on colour alone: icon plus text. Rule 1 of section 02 applies to state as well as to type.
- Field order follows the task, not the database.
Tables and lists
The table is the main working surface. It needs sorting, selection, bulk actions and a page size before it needs anything decorative.
| Part | Spec |
|---|---|
| Header | Sticky, 10.5 uppercase --ink-3. The sorted column names its direction. |
| Row | 44 px, separated by a 1 px --line. No zebra striping — the lines do that job without tinting every second row. |
| Selection | 34 px column on the left, header checkbox selects the page, not the set. Say which. |
| Alignment | Text left, numbers right and tabular, dates left in one fixed format. Never centred. |
| Actions | Right, in a menu. Never visible on hover only — a keyboard cannot hover and a touch screen has no hover at all. |
| Empty cell | On em dash. Never blank, never "N/A", never "null". |
- Long values truncate with an ellipsis and carry the full text in
title. A data table does not wrap; wrapping makes every row a different height and the eye loses the column. - The bulk bar appears at one selected item and states the count: "3 selected". It says what will happen to those three.
- Sort, filter, page and tab live in the URL. A view that cannot be linked cannot be handed to a colleague.
- Sorting returns to page 1. Staying on page 4 of a re-sorted set shows a random slice.
- 25 rows by default, and the total is stated. Infinite scroll only where nobody needs to reach the end — never in a list people work through.
- Column widths do not change between pages. Fix the layout, or every page turn re-reads as a new table.
- Row click opens the detail, the checkbox does not.
| Title | Status | Language | Views | Changed | ||
|---|---|---|---|---|---|---|
| Pricing page 2026 | Published | DE | 12,480 | 28.08.2026 | ||
| Assade case study | Review | DE | 3,902 | 27.08.2026 | ||
| Newsletter 09/26 | Draft | EN | — | 27.08.2026 | ||
| Why EU hosting matters | Published | DE | 840 | 24.08.2026 |
Empty, loading and failure
Four empty states, and they are not interchangeable. Offering "write your first post" to someone who has just migrated 400 posts is the failure this section exists to prevent.
| State | Because | Offers |
|---|---|---|
| First use | Nothing exists yet | Create and import, side by side, equally weighted. Section 17. |
| No result | A filter excluded everything | Names the filter and clears it. Never the first-use screen — the data exists, it is hidden. |
| No access | The role is not allowed | Says who can grant it. Section 16. |
| Failure | Something broke | What failed, and a retry. Never a bare code. |
Waiting
| Duration | Show |
|---|---|
| under 300 ms | Nothing. A spinner that flashes reads as a fault. |
| 300 ms – 1 s | A skeleton in the shape of the result. |
| 1 – 10 s | Skeleton plus one line saying what is happening. |
| over 10 s | A background task with progress. The user leaves the page and is told when it is done. |
- The skeleton has the layout of the result. Otherwise the page jumps the moment data arrives, and the jump costs more attention than the wait.
- Never a full-page spinner over content that is already there. Load in place.
- Inline, banner or toast. Inline where the failure is local to one field or row. A banner where the whole page is affected. A toast only for the result of something the user just did.
- 403, 404 and 500 keep the shell. A bare error page loses the navigation and with it every way out.
- Offline is a persistent banner, and unsent form content survives it.
Create the first one, or bring your existing ones across from the old system.
128 posts exist; the filter is hiding them.
Only owners can see billing. Emma Woelk can grant you the role.
The connection dropped. Your filters are kept.
Overlays and notifications
Four kinds of overlay, one job each. Choosing the wrong one is the most common mistake in a product of this shape.
| Type | For | Spec |
|---|---|---|
| Dialog | A decision that has to be made now | 420 for a confirm, 640 for a form. Centred, scrim rgba(20,18,35,.42). |
| Sheet | A task beside the context it belongs to | Right, 480 wide, full height. The list behind stays readable. |
| Menu | A choice among actions | Anchored to its trigger, 210 wide, destructive item last and separated. |
| Tooltip | The name of an icon | Never information needed to act. Does not exist on touch. |
- Focus moves into the overlay and returns to the trigger. Escape closes the topmost layer, and only that one.
- The confirm button says what happens. “Delete”, not “OK”, not “Yes”. The title names the object: “Delete “Pricing page 2026”?”
- A destructive confirm does not hold focus on the destructive button. Enter is pressed by reflex.
- Toast: bottom right, 5 s, three at most. It reports something that already happened and carries the undo.
aria-live="polite", or a screen reader never hears it. - Errors the user must act on are never toasts. A toast leaves before it is read and cannot be recalled.
- One layer. A dialog does not open a dialog. If it must, it is a form that was cut in the wrong place.
- Under 768 px a dialog becomes a full-height sheet. Section 20.
The AI surface
This is the screen the product is sold on. It carries more rules than any other, and the first of them is that a person signs off before anything leaves the building.
What the surface is made of
| Part | Spec |
|---|---|
| Composer | The only free-text field on the screen. Section 07. |
| Model row | Directly on the composer: which module, and where it is processed. Not a grey line in the footer. |
| Message | Max 720 px, roughly 70 characters a line. Section 04. |
| Thinking | Collapsed by default, --ink-2 on --sunken: 6.11:1, against the roughly 3:1 it has today. |
| Sources | Under the answer, as chips, each one openable. On answer with no source says so. |
| Actions | Copy, regenerate, approve. Regenerate keeps the previous answer reachable. |
- Send and stop are the same button. While the answer streams it says stop; it never sits beside a send button.
- Stopping keeps what has arrived. Discarding a half-written answer discards work the user was reading.
- Everything generated carries the gold marking from section 05 until a person approves it. Approval is a named action, never a side effect of editing.
- Nothing generated publishes without that step. This is a rule about liability, not about taste.
- The module names are fixed:
TEISENDA Studio Engine,TEISENDA Content Engine. The other five are roadmap and must not appear as available — Frontend section 05. - A refusal is not an error. It is not red, it does not offer a retry, and it says what the model will do instead.
- A model error names the next step. “Answer stopped. Try again, or ask something shorter.” Never a bare code.
- What a run costs is visible before it runs, if it costs anything.
- German is
du, per the table in Frontend section 07. Consistent across composer, error and mail.
Reasoning
Collapsed by default. --ink-2 on --sunken is 6.11:1, against the roughly 3:1 it has today.
Roles, access and settings
What a member can see is a product decision, and it surfaces on every screen. The interface has to state it rather than discover it at the moment of failure.
- The navigation shows what the role can do. On entry that leads to a permission wall does not belong in it.
- A permission wall names who can grant access, by name, and offers to ask them. Section 12.
- Roles are visible in the member list, along with status — eingeladen, aktiv, deaktiviert — and last activity.
- An invite states the role before it is sent, not after it is accepted.
- An API key is shown once, at creation, with a copy button and a plain warning. Never retrievable afterwards. A key that can be read back is a key that leaks from a screenshot.
- The danger zone is separated and last, and its actions require typing the object's name. A confirm dialog alone is a reflex away from a deleted workspace.
- Billing states the plan, the period, the next charge with date and amount, and what happens at the limit. "Soon" is not a billing state.
Settings, in this order
Account · Workspace · Members · Billing · Integrations · API · Danger zone. Personal first, shared second, dangerous last.
| Member | Role | Status | Last active |
|---|---|---|---|
| Emma Woelk | Owner | Active | 4 min ago |
| redaktion@teisenda.com | Editorial | Invited | — |
Copy it now. Once this closes it cannot be retrieved.
128 posts and every member. Type teisenda to confirm.
Onboarding and import
The first screen after sign-up decides whether the product gets used. For a customer arriving from another system, the common first act is import, not authorship.
- Import sits beside create on every empty list, equally weighted. Not in a settings page, not behind a menu.
- A checklist has at most five steps, is dismissible, and does not come back once dismissed.
- Sample data is labelled as sample and removable in one action. Sample data that cannot be told apart from real data poisons the first week.
- Where data is processed is stated once at first run, prominently, and then lives on the model row — section 15. Repeating it on every screen turns a selling point into furniture.
- Import reports rows read, imported and skipped, with the reason per skipped row and a file of them to download. “412 of 428 imported” without the missing 16 is an unfinished job handed to the customer.
- An import that takes longer than ten seconds is a background task. The user leaves the page and is told when it is done — section 12.
Motion, keyboard and focus
Motion has one job: to say what moved where. Past 200 ms it stops informing and starts delaying.
| What | Duration | Easing |
|---|---|---|
| Colour, hover | 120 ms | linear |
| Menu, toast | 160 ms | cubic-bezier(.2,.7,.3,1) |
| Dialog, sheet | 200 ms | same entering, cubic-bezier(.4,0,1,1) leaving |
| Anything else | 240 ms ceiling | — |
prefers-reduced-motionis respected — rule 3 of section 02. Transforms become opacity, durations go to0.01ms, and nothing exists only as motion.- Never animate the height of a list. It reflows everything below it on every frame.
- Tab reaches everything actionable, in the order it is read. A control reachable only by mouse is a control half the users do not have.
- The focus ring is never removed, and a custom control brings its own. 2 px
--violetoffset 2 on light,--violet-lifton the sidebar. - Escape closes the topmost layer and returns focus to the trigger.
- A skip link to the main content is the first tabbable element, visible on focus. Without it every keyboard user tabs through the whole sidebar on every page.
- Shortcuts: ⌘K search, ⌘↵ send, Esc close. No single-letter shortcut fires while a text field has focus.
- Colour is never the only carrier of a state — icon or text alongside. Applies to status pills, errors and the AI marking (section 05).
- Hit target 24 px, 44 on touch. Section 09.
Writing in the interface
German, du, per the language table in Frontend section 07. Interface copy follows the brand rules minus the rhythm: nobody reads a button for its cadence.
The examples in this section are in German, and only here. Everything else in this guide, the mockups included, is English. These are not: number format, date format and the difference between du and Sie do not survive translation, and they are what this section is about.
- Buttons are verb plus object. "Beitrag löschen", never "Löschen", never "OK", never "Ja".
- Titles take no full stop, sentences do.
- Sentence case, not Title Case. German capitalises nouns; adding English title casing on top makes both wrong.
- The error formula: what happened, why if it helps, what to do now. One sentence each at most.
- Never "Ups", never "Etwas ist schiefgelaufen". Both say nothing and cost trust at the exact moment it is thinnest.
- Dates:
28.08.2026. Relative time only under 24 hours, and the absolute value always intitle. State the timezone wherever it can differ. - Numbers in German format:
12.480, decimals with a comma. Tabular figures in tables — section 08. - An empty state names what would be here, then offers the action. Section 12.
- Product names are not translated.
Studio EngineandContent Enginestay as they are — Frontend section 05. - No abbreviation the reader has to decode. "Bald" is a label, not an explanation of when.
- One term per thing. Beitrag or Artikel, not both; whichever is chosen is the one in the navigation, the button, the empty state and the error.
Responsive
The product is a desktop tool. Mobile is for reading and approving, not for building — and that has to be said rather than discovered.
| Width | Sidebar | Table | Dialog |
|---|---|---|---|
| ≥ 1280 | 246 px, open | Full | Centred |
| 1024 – 1279 | 74 px rail | Full, fewer columns | Centred |
| 768 – 1023 | Drawer | Full, fewer columns | Centred |
| < 768 | Drawer | Cards, label above value | Full-height sheet |
- Checked at 1280, 1440 and 1920, with the keyboard path visible throughout — section 04.
- A horizontally scrolling table is not the mobile answer. Below 768 a row becomes a card: label above value, the two or three fields that matter, and the action.
- The rail needs a logo without the wordmark.
icon-*is the butterfly alone — Frontend section 02. - The composer stays above the on-screen keyboard.
- Touch targets 44 px below 768, hover states dropped: there is no hover on touch, so nothing may depend on it — section 11.
- What cannot be done on mobile says so, in place, instead of offering a control that will not work.
Label above value, the fields that matter, one action at the bottom at 44 px. No sideways scrolling, and nothing that needs hover.
Reporting and charts
A figure in this product is evidence for a decision. It has to say what it counts, over which period, and against what.
Which chart
| Question | Chart | Not |
|---|---|---|
| How did it develop? | Line, or area for a single series | Bars. Twelve bars over twelve months hide the trend they are meant to show. |
| How do these compare? | Bars, sorted by size | A line. A line between unrelated categories draws a trend that does not exist. |
| What is it made of? | Donut, at most four slices, largest first | More than four slices, or a pie for anything anyone has to read a value off. |
| Where does it stand right now? | A figure with its change and period | A gauge. It costs the space of a chart and carries one number. |
| Two measures at once? | Two charts | A second Y axis. Two scales in one frame can be made to show any relationship at all. |
Series colours
One series is --violet. Two are --violet and --ink-3. Beyond that, in this order and no other: #8250E6 · #432B78 · #B49BF0 · #6A4394 · #D8CBF2. A sixth series means the chart is the wrong shape.
- Gold is never a series. It marks AI and nothing else — section 05. A gold bar makes the reader ask whether that column was generated.
- Red and green only where they mean bad and good. As neutral series colours they announce a judgement nobody made.
- Label the series directly, at the end of the line or above the bar. A legend forces a match by colour, which is exactly what a colour-blind reader cannot do.
- A bar chart starts at zero. A truncated axis misstates the proportion, and the proportion is the whole reason for bars.
- Horizontal grid lines only, 1 px
--line. No vertical grid, no shadow on the plot, no third dimension. - Every axis carries its unit, every time series its granularity and its timezone.
Figures
- Show the number, not only the shape. A sparkline without a value beside it is decoration.
- A change carries sign, size and period: “+12 % vs. previous month”. An arrow alone says neither how much nor against what.
- Direction is never carried by colour alone — sign and word alongside.
- A percentage states what it is a percentage of.
- Tabular figures, German format, empty value an em dash — sections 08 and 19.
- No data is “No data yet”, not a flat line at zero. A zero line is a measurement; an empty set is not.
- Rounding is decided once and kept. 12,480 in the table and 12,5 Tsd. on the card invites the question which one is true.
The control bar
Every reporting screen carries the same four controls, in this order, above the first chart.
| Control | Spec |
|---|---|
| Period | Presets — Today, 7 days, 30 days, 12 months, Custom. The chosen one is marked, and the absolute dates are written out beside it. “Last 30 days” without the dates is unciteable. |
| Comparison | Off by default. One choice, never two: “vs. previous period” or “vs. previous year”. When it is on, every figure on the screen carries its delta — not only the chart. |
| Segment | Chips, multiple allowed, the count stated when more than one is active. Filters live in the URL — section 11. |
| Freshness | "As of 14:32". A report is a snapshot; a number without a time cannot be quoted. |
| Export | CSV of the data behind the view with the filters applied, PNG of the chart as shown. Both carry period and filters in the filename. |
- A chart that updates itself while it is being read. If the data moves, say “New data available” and let the reader decide.
- A period that resets when the reader navigates back.
More marks
| Mark | Use it when | Rule |
|---|---|---|
| Stacked bars | The parts and the total both matter | Only when the total means something. Otherwise group them side by side — a stack makes every segment except the bottom one impossible to compare. |
| Horizontal bars | Labels are longer than about twelve characters | Sorted by size. Rotated axis labels are not the answer to long labels; turning the chart is. |
| Heatmap | A cycle — weekday by hour | Never for a trend. One hue, five steps, and the scale is stated. Section 21's series list does not apply; a sequential scale is a different job. |
| Funnel | Steps that are genuinely sequential | Each step is a subset of the one above. If someone can skip a step, it is not a funnel and the drop-off figures lie. |
| Sparkline | Inside a table row or beside a figure | No axis, and never without its current value beside it. |
| Target line | There is an agreed number to hit | Dashed, --ink-3, labelled at the right end. Never in a series colour — a target is not a measurement. |
Titles on the left, sorted by size. Rotated axis labels would be unreadable here.
Each step is a subset of the one above. If a step can be skipped, this is not a funnel — it is four numbers.
| Post | Trend | Views |
|---|---|---|
| Why EU hosting matters | 18,402 | |
| Pricing page 2026 | 12,480 |
No axis, and never without the value beside it.
Axis, tooltip, legend
- At most five ticks on the Y axis, abbreviated in German —
Tsd.,Mio.— with the unit in the chart title, not repeated on every tick. - Fewer X labels rather than rotated ones. Below roughly 60 px per label, drop every second and keep the first and the last.
- The tooltip opens on hover and on focus, shows the x value, every series with its colour and value, and the delta when comparison is on.
- No number that matters lives only in a tooltip. A figure someone has to hover to find is a figure that will not appear in their report.
- The legend states values, not just names, wherever there is room. A legend that only names colours makes the reader look twice.
- Clicking a legend entry may hide a series; it must not change the axis scale, or the remaining series appear to jump.
When there is nothing to draw
- Loading is a skeleton with the plot area and the axes, not a spinner — section 12.
- Empty says “No data yet” inside the plot area, with the axes still drawn. A chart that vanishes leaves the reader unsure whether it failed.
- On incomplete last bucket is marked — hatched, and named: “August is still running”. Without it, every month-to-date chart reads as a collapse.
- An error stays in the card: what failed, and a retry. Not a toast — section 13.
- A comparison with no counterpart says so rather than drawing a flat line at zero.
August is still running — 12 days.
Your period is kept.
Drilldown and annotations
- A click on a bar filters the table below it. It does not navigate away — the reader came for the comparison and would lose it.
- The active filter is visible as a chip and clears in one action, exactly like section 11.
- Annotations — releases, campaigns — are a thin vertical line with a marker at the axis, at most five in view. Beyond five they are a second chart nobody asked for.
- An export carries what was on screen: period, filters, comparison. A CSV that silently exports everything is worse than no export.
Motion
- A chart draws itself once, on arrival, in under 1.2 s. Bars grow from the baseline, lines draw left to right, the donut fills clockwise from twelve.
- Stagger by 60 ms per element, and stop after that. Nothing loops, nothing pulses, nothing animates on hover. A dashboard that keeps moving cannot be read.
- The final state is the default. The animation runs from empty to it, never the other way round — so a browser that skips the animation shows the finished chart, not an empty frame.
- Values never animate on update. A figure that counts up while someone reads it is a figure they read wrong.
prefers-reduced-motionremoves all of it — section 18.
Reports
Last 12 months| Post | Language | Views | Avg. read time | Source |
|---|---|---|---|---|
| Why EU hosting matters | DE | 18,402 | 3:02 | Editorial |
| Pricing page 2026 | DE | 12,480 | 1:48 | KI |
| Assade case study | EN | 3,902 | 2:31 | KI |
The chart engine
One charting library, named here, with the case for it and the one point at which it stops being the right answer.
Recharts, through the shadcn/ui chart wrapper
npx shadcn@latest add chart /* Recharts plus the token wrapper */
| Why | What it buys |
|---|---|
| It is already the stack's default | Section 03 puts the tokens into shadcn. Its chart component is Recharts with a wrapper that reads those same variables. One system, not two. |
| SVG, not canvas | The labels are real text: selectable, searchable, reachable by a screen reader, and they survive print and PDF. A canvas chart is one image with no words in it — and this product exports reports. |
| Colour comes from CSS variables | The series list of section 21 is the only place colours live. No second palette inside a chart config. |
| Composable | Axis, grid, tooltip and legend are separate components. The rules in section 21 can be enforced once, in a wrapper, instead of per chart. |
| MIT, no network | No CDN, no telemetry, no font fetched at runtime. The rule in section 04 applies to libraries too. |
| Animation is per series | isAnimationActive binds to reduced motion in one place — section 18. |
Where it stops
- Recharts draws one SVG node per point. Past roughly 5.000 visible points it becomes slow, and no amount of memoising fixes that.
- Above that, one chart may use uPlot — canvas, 45 KB, MIT. For that chart, not as a second general library, and the axis, tooltip and colours still follow section 21.
- No second library for convenience. Two libraries mean two sets of defaults, two tooltips and two legends, and the difference shows on the first screen that has both.
Considered and not taken
| Library | Why not here |
|---|---|
| Chart.js | Canvas. No real text, so no selection, no screen reader, and blurred labels in a PDF. |
| ECharts | Around a megabyte, an imperative API against a React tree, and its own theming system that would fight the tokens. |
| Nivo | Heavy, and its visual defaults are strong enough to become a second brand. |
| Tremor | Opinionated dashboard styling on top of Recharts — a layer whose only job here would be to be overridden. |
| visx | The most control and the most work: every axis, tooltip and legend hand-built. Right only once the standard set genuinely runs out. |
| D3 directly | The same, further along, and it puts a second rendering model beside React. |
The configuration, in one file
Colours, axes and motion are set once. A component that sets them again is the beginning of a second chart style.
// chart-theme.ts — the series list of section 21, nothing beside it export const series = [ "var(--violet)", "var(--violet-deep)", "var(--violet-lift)", "#6A4394", "#D8CBF2", ] as const; export const axis = { stroke: "var(--line)", tickLine: false, axisLine: false, tick: { fill: "var(--ink-3)", fontSize: 11 }, }; // Horizontal lines only, at most five steps — section 21 export const grid = { vertical: false, stroke: "var(--line)" };
// One chart. The height sits on the container: without it // ResponsiveContainer collapses to zero and nothing appears. const quiet = useReducedMotion(); <ChartContainer config={config} className="h-[280px] w-full"> <LineChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: 0 }}> <CartesianGrid {...grid} /> <XAxis dataKey="month" {...axis} tickMargin={8} /> <YAxis {...axis} width={44} tickCount={5} tickFormatter={shorten} /> <ChartTooltip content={<ChartTooltipContent />} /> <Line dataKey="now" stroke={series[0]} strokeWidth={2.5} dot={false} isAnimationActive={!quiet} animationDuration={900} /> <Line dataKey="lastYear" stroke="var(--ink-3)" strokeWidth={2} strokeDasharray="5 5" dot={false} isAnimationActive={!quiet} /> </LineChart> </ChartContainer>
- Never a literal hex in a chart component. Colours come from
series, in order. A hex in a component is a colour nobody will find when the palette changes. isAnimationActiveis bound to reduced motion in the wrapper, not per chart. Bound in twelve places it will be wrong in one of them.- One
ChartContainerper chart, its config beside it. No chart reaches into another chart's config. - Height is explicit.
ResponsiveContainertakes its width from the parent and its height from nothing — without a height it renders at zero and the card looks broken rather than empty. - No chart on the server. Render the skeleton of section 21, hydrate, then draw.
- The tooltip is
ChartTooltip, once. A custom tooltip per chart is how two charts start disagreeing about what a number means. - Number formatting comes from one formatter — German format, tabular figures, the abbreviations of section 21. Recharts will happily print
12480.0000001if nobody stops it.
Why this page does not load it
The examples in this guide are drawn as plain SVG, not rendered with Recharts. This document is a reference that makes no external request and carries no framework; pulling in React and a charting library to draw six example charts would cost more than the rest of the page together. The drawings match what the configuration above produces — if the two ever disagree, the configuration is right and the drawing is the bug.
Glow and emphasis
Glow says here. It never says what. Four places carry it and nothing else does — a screen where three things glow has nothing emphasised.
:root{
--glow-v: 0 3px 16px rgba(130,80,230,.34); /* violet, resting */
--glow-v-lg: 0 5px 26px rgba(130,80,230,.48); /* violet, hover */
--glow-g: 0 3px 16px rgba(237,169,56,.32); /* AI */
--glow-focus: 0 0 0 4px rgba(130,80,230,.20); /* halo behind the 2px ring */
}
| Where | Which | Why there |
|---|---|---|
| Active sidebar item | --glow-v | The answer to "where am I", on the darkest surface in the product, where a glow actually reads. |
| The one primary action | --glow-v, --glow-v-lg on hover | One per view — section 09. The glow is what makes that rule visible. |
| Anything from the model | --glow-g | The gold marking of section 05, given weight. Never on anything a person wrote. |
| Focus | --glow-focus | Behind the 2 px ring, never instead of it. The ring is the state; the halo only makes it findable. |
| The live point in a chart | drop-shadow on the last data point | Section 21. One point, the current one. |
- Glow lies outside the element and changes no contrast value. That is what makes it safe, and it is also why it can never carry a state on its own.
- At most one glowing element in view, plus focus, plus the AI marking. Beyond that it is wallpaper.
- Never on a card, a field, a table row or a status pill that is not AI. A glowing card is a card claiming an importance it does not have.
- Never animated. No pulse, no breathing, no shimmer — section 21.
- Only violet, gold and the destructive red. A glow in a colour outside the palette invents a fifth meaning.
- It is strongest on the dark sidebar and weakest on white. On
--cardkeep to the resting value; the large one there reads as a printing error.
Integration commands
Everything in this part is reachable from a terminal. Versions are pinned where the guide ships a specific one, so the product and this page cannot drift apart.
Components and tokens
The values of section 03 go into the shadcn tokens, not beside them. shadcn expects bare HSL triples without the hsl() wrapper — the hex values have to be converted, not pasted.
# The scaffold and the components this part describes npx shadcn@latest init npx shadcn@latest add button input textarea select checkbox switch label \ dialog sheet dropdown-menu tooltip table tabs skeleton sonner badge # Charts from section 21 (shadcn/ui charts, on Recharts) npx shadcn@latest add chart
Assets from this guide
# Icons — the same library /icons/ comes from npm i lucide-react # Flags, exactly the version in Frontend section 06 npm i flag-icons@7.5.0 # Type: no package, no request to Google. Fetch the four subsets # this page uses itself. mkdir -p public/fonts && cd public/fonts for f in InstrumentSans-latin InstrumentSans-latin-ext \ InstrumentSans-Italic-latin InstrumentSans-Italic-latin-ext; do curl -fsSLO "https://ci.teisenda.com/fonts/$f.woff2" done # The butterfly. One file, no build, no dependency. curl -fsSLO https://ci.teisenda.com/effects/butterfly-field.js
Checks that belong in CI
Each one corresponds to a rule in this part. A rule nobody checks is a rule that lasts until the next sprint.
# No request to a Google domain (section 04) ! grep -rn "fonts.googleapis\|fonts.gstatic" src/ public/ # No outline:none without a replacement (section 09) ! grep -rn "outline:\s*none" src/ --include=*.css # No logo with a live text layer (Frontend section 02) ! grep -l "<text" public/logos/*.svg # The butterfly carries the same attributes everywhere (section 06) test "$(grep -rho '<butterfly-field[^>]*>' src/ | sort -u | wc -l)" -le 1 # Operability against the rules of section 18 npx @axe-core/cli http://localhost:3000 --exit
The product's own API
The key comes from Settings · API and is shown once — section 16.
# Read posts curl -fsS https://api.teisenda.com/v1/posts \ -H "Authorization: Bearer $TEISENDA_API_KEY" \ -H "Accept: application/json" # Verify the webhook signature before reading the body printf '%s' "$RAW_BODY" \ | openssl dgst -sha256 -hmac "$TEISENDA_WEBHOOK_SECRET" -binary \ | base64
- The key in the repository, in an image, or in a screenshot for support. It is shown once for that reason — section 16.
- Reading a webhook body before the signature has been checked.
Host and paths above are the shape, not a promise — the API is not among the things this guide fixes. Confirm them against the product before writing against them.
Stages and open questions
The order is a recommendation. If the existing code suggests another, say so before starting.
| Area | Sections | Done when | |
|---|---|---|---|
| E0 | Tokens, fonts, spacing and type scale | 03, 08 | The values apply project-wide, one scale is in use, and no request reaches a Google domain |
| E1 | Controls in every state | 09 | Every control has all six states and a visible focus ring, including the custom ones |
| E2 | App shell and navigation | 04, 14 | Checked at 1280/1440/1920, the same role sees the same sidebar everywhere, and the "Soon" question is decided |
| E3 | Login, including the butterfly and mobile | 06, 20 | Password managers work and the legal links are reachable on a phone |
| E4 | Forms | 10 | Errors say what to do, submit is never disabled to express validation, and unsaved work warns |
| E5 | Lists, tables, empty and waiting states | 11, 12 | All four empty states exist and are distinguishable, and every view is a URL |
| E6 | Overlays and notifications | 13 | Focus enters and returns, Escape closes one layer, and no error the user must act on is a toast |
| E7 | Studio: the AI surface | 15 | One free-text field, the model row on the composer, and nothing generated publishes without a human step |
| E8 | Settings, roles, danger zone | 16 | The navigation matches the role, the key is shown once, and deletion requires the name |
| E9 | Onboarding and import | 17 | Import sits beside create on every empty list, and skipped rows come with their reason |
| E10 | Reporting | 21, 22 | Every chart states its unit, its period and its timezone, and each one draws itself once and stops |
| — | Motion, keyboard, interface copy, figures, glow | 18, 19, 21, 23 | Not a stage. Each of the ten above is checked against them before it is called done |
| — | The commands for all of it | 24 | Not a stage either. The checks belong in CI on the first day, not after the first regression |
Still open
Not to be invented. Ask.
| Open | The question |
|---|---|
| Detail page | Header with tabs, or a two-column layout with a side panel. Where the actions live, and whether editing happens in place or in a form. |
| Dark mode for the work area | The sidebar tokens exist. Whether the work area follows, and whether it is a setting or the system preference. |
| Projects | Whether a project is an object at all, or whether the workspace is the only level. |
| Notifications | On inbox with a history, or only toasts. Toasts alone mean anything missed is gone. |
| Audit log | Whether who changed what is visible in the product. For a tool sold on data residency the answer is usually yes, but it is not a design decision. |
| Files and assets | Upload, storage, reuse across posts. Nothing here covers it. |
Three decisions block work and belong to the product, not to the design: the global AI bar in the studio, the fallback for chat titles, and how the "Soon" entries are handled.