A portfolio website for sustainable stylist Tina Steinke in Berlin, following up on a previous sustainable style consultancy in Düsseldorf. Visual design by Ina Nixdorf, technical concept and development by Ingo Steinke, announced and described as an exceptional portfolio project after going live in September 2023.
- canonical domain:
- alternative domains
Local development: http://localhost:1234/ Backend: http://localhost:1234/wp-admin (default credentials: admin:secret). Make sure to that the theme Kleiderordnung got activated or activate it manually after installation.
Features:
- accessibility: tab-navigateable, high contrast, respecting reduced motion preferences etc., still not 100% WCAG-compliant yet,
- animated using animations and scrolling effects unless user prefers reduced motion
- beautiful: web design based on the visual corporate identity
- classic: hybrid theme with custom post types, custom fields, and block editor support
- documented: README for developers and an additional manual for website owners
- editable: content, images, and translations can be edited by the site owner
- international: localized by a professional translator and proofreader
- minimal: all important content at a glance on one landing page
- portable: theme, plugins, and content can be moved from server to server
- printable: distinct style sheet to ensure print / PDF layout
- quick: cacheable and without loading unnecessary data
- responsive: designed and tested to work on any device and browser
- semantic: meaningful markup to ensure technical search engine optimization
- tested: automated and manual frontend tests plus static code analysis
- transparent and versioned open source development on GitHub
The theme kleiderordnung is a standalone WordPress theme not depending on any parent theme, but inspired by existing themes like Twenty-Twenty-Three, GeneratePress, and Fasto. This is a classic theme with partial block editor support. I have released a similar classic/hybrid WordPress theme development setup as @openmindculture/wp_template_opinionated on GitHub.
The theme does not call wp_head()
but emits a custom minimal head element instead.
Note that the theme' text domain (technical handle and directory name) is kleiderordnung (not kleiderordnung-berlin
– which is the name of the project used to develop and maintain the theme).
Kleiderordnung's graphic design has been conceived in collaboration with Ina Nixdorf and Martina Steinke (Kleiderordnung Berlin). Technical details and development: Ingo Steinke. Thanks to Andy Weisner (RetinaFunk) and Daniel Groner for support and (partial) code review! Thanks to Jessica Lyschik, Carolina Nymark (FullSiteEditing.com), Sara Soueidan and many other experts contributing to open source web development!
- Henderson Sans by Sudtipos
- Ivy Mode by Jan Maack from Ivy Foundry
To setup / deploy kleiderordnung on a website,
- install WordPress core and the required plugins,
- copy all
wp-content/mu-plugins/*.php
files manually, - setup Polylang:
- German (Deutsch) as default language
- English (EN-US) as additional language
- enable translation of custom post types (Languages/Sprachen -> Settings/Einstellungen -> Custom Post Types and Taxonomies / Benutzerdefinierte Inhaltstypen und Taxonomien -> check all checkboxes) before adding (or importing) content!
- install the lastest Kleiderordnung theme by uploading it in WP-Admin (or via (S)FTP),
- activate the theme,
- copy all icons and manifest files (
*.ico
,*.png
,*.svg
,*.json
,*.xml
,robots.txt
) to the web root directory - configure server and caching by editing
.htaccess
/nginx.conf
/ hosting backend or by using appropriate plugins, - use additional reverse proxy and/or CDN servers if necessary,
- run automated tests (
npm run test
) and update the baseline screenshots if necessary (see Testing...) - test everything and back up your data regularly: use WP-Admin Tools / Werkzeuge -> Export or a plugin like Updraft Plus.
There is a (German) end-user manual aiming to show non-technical site owners how to edit and add content in the WordPress admin backend, available at doc/anleitung.md and linked from the custom admin dashboard widget which contains direct links to edit different custom content types and generate a content export file.
Enter WP-Admin by clicking on "edit page" ("Seite bearbeiten") if you have already logged in before:
Add, edit and preview news (blog posts):
Add, edit and preview stories (testimonials):
Colors to distinguish between local host, preview and production servers:
Design and behavior is controlled by the theme and plugins which can be deployed to production after testing. Content (data) is edited on the production server and synchronized back to test and development systems.
Upload
- the theme (ZIP) via wp-admin
- the
mu-plugins
via (S)FTP (NOT included in the theme file!)
Download
content.xml
via WordPress tools export / import
Existing posts and pages will be replaced when importing. This is non-standard configured behavior which will only work if mu-plugins/wordpress-import-update.php
is present!
DON'T commit any personal data (possibly included in a complete "all content" export file). Make sure to review and clean up any content.xml
before committing!
Review and synchronize settings manually, as far as you find useful. Make sure that all necessary plugins are installed.
- address/url: https://...
- reading: front page / home page shows a static page: home page German (language will be handled by theme and Polylang plugin)
- permalink structure: post name + id:
/%postname%-/%post_id%/
- media library not organized in year/month folders
The portfolio website content can be edited and extended using the WordPress admin backend. This is a classic WordPress theme, without full-page editing or full theme engine support, but allowing to use the block editor where it might be helfpul.
The page structure and design elements are fixed by the theme, based on the main landing page which can not be fully edited in the block editor. Instead, custom fields or widgets are provided to ensure non-destructive editing limited to the intended content type (plain text or rich text with limited markup options).
- page (front / home / landing page, imprint, privacy, about me)
- The main index landing page (front page / home page), defined in
front-page.php
according to the class template hierarchy, contains most of the content and links / teasers to additional posts and pages. - Text lines and paragraphs must be editable, probably as custom fields, using either plain text or very limited styling like bold, italic, underline, marker, link:
page_intro_headline
page_intro_text
page_mission_headline
page_mission_text
page_cta_text
page_contact_teaser_text
page_contact_job_title
- Any other regular static page, like privacy, imprint, TOS (AGB), is displayed by
page.php
and edited in the block editor.
- The main index landing page (front page / home page), defined in
- post (News blog posts / teasers)
- category title = over headline
- post title = teaser headline / text
- post image = main post image
- story (Stories / Testimonials) localized
- post title = headline: testimonial name and short description
- post text = quotation
- post image(s) = main image (and possibly more optional images)
- position_number (text) in
story_field_group
(sidebar)
- offer (Angebot) localized
- post title = main headline
- post image = main post image (only in main offer section)
- post text = intro text
- offer_features ("Was du erhältst") (text) in
offer_field_group_main
- offer_price (text) in
offer_field_group_main
- offer_price_annotation (text) in
offer_field_group_main
- offer__id (text) in
offer_field_group_sidebar
- offer_position_number (text) in
offer_field_group_sidebar
- custom taxonomy: offer_section NOT localized
angebot
("Angebot")weitere
("Weiteres Angebot")
- general translations managed by Polylang, like messages and button captions can be edited by the site owner on the languages -> translations page.
- social media placeholders (see below)
- content handled by plugins or widgets:
- Instagram section (external feed via juicer.io)
- contact form (widget shortcode)
- carbon badge (external content)
There must be exactly 1 active contact form for each language. The form titles must contain the language string ("en_US" or "de_DE"), so the theme can display the correct contact form dynamically without hard-coded form IDs, to work around the problem that contact form 7 posts won't be translated by the current Polylang plugin.
Form field names must be defined explicitly so that name and email/phone can show up correctly in the saved flamingo inbox list view. In each form settings "additional settings" tab, add
flamingo_email: "[Email]"
flamingo_name: "[Name]"
flamingo_subject: "Contact Form"
To enable an alternative email or phone field, we define it as text
type and don't validate its input. But we could still use email autocompletion. Akismet spam protection can recognize email, but no phone, so we could mark it as akismet:author_email
but that might compromise legitimate submissions that provide a phone number. That leaves us with akismet:author
as the only explicit anti-spam markup in our form.
<div class="contact__fieldset contact__fieldset--from">
<label class="contact__label contact__label--name">
<span class="screen-reader-text">Name</span>
[text* Name autocomplete:name akismet:author placeholder "Name"]
</label><!-- NO LINE-BREAK HERE ! --><label class="contact__label contact__label--email">
<span class="screen-reader-text">Email address </span>
[text Email autocomplete:email placeholder "Phone or email address"]
</label>
</div>
Localization / translation needs to be enabled explicitly in Polylang Languages -> Settings -> Custom Post Types and Taxonomies:
- Stories (Testimonials) (story)
- Angebote (offer)
- Angebotskategorien (offer_section)
Pragmatically working around Meta Query issues when using custom post types, custom ACF fields, and polylang translation in the same theme, all custom fields are text fields. Custom position number fields are simple tools to let site owners control the display order of items that are not sorted by date. If the optional plugin Post Types Order is active, custom position numbers are ignored in the frontend and hidden in the backend.
Each offer should have a unique, permanent ID, used as an anchor for sub-navigation and for external deep linking. The offers and their IDs should also match the checkbox options in the contact form. Icons on top of the offer cards are displayed according to one of the known offer IDs.
Offer taxonomies currently serve to separate main offers, displayed with cards and images, from "other offers". There is no point in localization / translation.
Angebot is not called "product" to avoid conflict with optional shop plugins.
- Header: the (sticky) header bar with logo and main navigation
- Main:
- Intro Section
- Key Visual (hero image / hero header / jumbotron)
- Key Headline (h1)
- Text Paragraph
- Offers Section (h2)
- sub-navigation
- offer article (post) (detailed, featured, main) as a WordPress (custom) post, marked up as an <article> with image, main headline (h3), text paragraphs, offer price etc.
- (alternative further offers in a smaller card layout)
- page decoration elements / section decoration elements
- Mission Section
- Stories Section
- story block quotes with icon, headline, paragraph
- News Section with text box, call to action text, button
- Social Media Section: Instagram images and link
- Contact Section
- contact information (vcard)
- contact form
- Intro Section
- Footer
-
Buttons (and call-to-action links in a button-like appearance)
-
Links (in a text-like appearance)
-
Headlines, bold text, and other typographic elements
-
Carousels or similar draggable / slideable elements
-
Decoration + Perspective Effects
-
Lists
-
Icons
-
Card-like grid / flex layouts
-
page header
-
Instagram posts
-
contact form
-
page footer
Colorful shapes are placed behind offers and subsequent sections. To make CSS perspective work, global html and body style is tweaked as explained here: pure CSS parallax perspective beyond landscape images. This might alter circumstances for subsequent styling and scroll events.
html {
height: 100%;
overflow: hidden;
}
body {
height: 100%;
height: 100vh;
height: 100dvh;
overflow-x: hidden;
overflow-y: scroll;
transform-style: preserve-3d;
perspective: 1px;
perspective-origin: 0 0;
}
.decoration__container { /* decoration-parallax-layer-background */
content: "";
position: absolute;
margin: 0;
transform-origin: top left;
transform: translateZ(-1px) scale(2);
top: 112rem; /* adjust to fit */
height: 187rem; /* adjust to fit */
}
Sections before and after should be set to position: relative; z-index: 2
to make sure that they stay in front of the decoration layer.
The carousel is a scrollable section with a flexbox of flexboxes, initially capped to a specific height and width. To compensate unexpected content, we adjust the designed styles:
- If a story has no image, its width is also used for the text.
- Text longer than a certain word count gets an additional style class name
long
so we can make it slightly wider. - An additional JavaScript-based adjustment detects overflowing text and increases the width accordingly.
The latter adjustment can be used anywhere in the document.
The function growWidthToPreventOverflow
works an any element with a grow-x
class name.
It is called when the document has been rendered.
A contact form is provided using the popular Contact Form 7 plugin and the Flamingo add-on to save messages in the database. For localized content, a new form needs to be created for each language.
Contact form settings use specific shortcodes, identifiers must match in form template, email template, style sheets, and possible JavaScript handlers. Checkbox elements show up with their value when checked.
Example:
Form markup:
<div class="contact__fieldset contact__fieldset--from">
<label class="contact__label contact__label--name"> <span class="screen-reader-text">Dein Name</span> [text* Name autocomplete:name akismet:author placeholder "Name"] </label><!-- NO LINE-BREAK HERE ! --><label class="contact__label contact__label--email"> <span class="screen-reader-text">Deine Telefonnummer oder E-Mail-Adresse </span>[text Email placeholder "Telefon oder E-Mail"] </label>
</div>
<div class="contact__fieldset contact__fieldset--message"><label class="contact__label contact__label--nachricht"> <span class="screen-reader-text">Deine Nachricht (optional)</span> [textarea Nachricht placeholder "Deine Nachricht"] </label></div>
<div class="contact__fieldset contact__fieldset--checkboxes">
<div class="contact__calltoactiontext--fieldset">Unverbindliche Anfrage für</div>
[checkbox anfrage-style-visit id:anfrage-style-visit class:contact__checkboxline class:contact__checkboxline--style-visit use_label_element "Style Visit"]
[checkbox anfrage-shopping-tour id:anfrage-shopping-tour class:contact__checkboxline class:contact__checkboxline--shopping-tour use_label_element "Shopping Tour"]
[checkbox anfrage-online-style-beratung id:anfrage-online-style-beratung class:contact__checkboxline class:contact__checkboxline--online-style-beratung use_label_element "Online Styling Beratung"]
[checkbox anfrage-start-up-consulting id:anfrage-start-up-consulting class:contact__checkboxline class:contact__checkboxline--start-up-consulting use_label_element "Start-Up-Consulting"]
[checkbox anfrage-gutschein id:anfrage-gutschein class:contact__checkboxline class:contact__checkboxline--gutschein use_label_element "Gutschein"]
[checkbox anfrage-ordnung-im-schrank id:anfrage-ordnung-im-schrank class:contact__checkboxline class:contact__checkboxline--ordnung-im-schrank use_label_element "Ordnung im Schrank"]
[checkbox anfrage-styling-abo id:anfrage-styling-abo class:contact__checkboxline class:contact__checkboxline--styling-abo use_label_element "Styling-Abo"]
</div>
<span class="contact__buttonwrapper contact__buttonwrapper--submit">[submit "Absenden"]</span>
Email template:
Subject: [_site_title] Kontaktformular
Additional header: Reply-To: [Email]
Message text:
Von: [Name]
E-Mail: [Email]
Unverbindliche Anfrage für: [anfrage-style-visit] [anfrage-shopping-tour] [anfrage-online-style-beratung] [anfrage-start-up-consulting] [anfrage-gutschein] [anfrage-ordnung-im-schrank] [anfrage-styling-abo]
Nachricht:
[Nachricht]
--
Diese E-Mail wurde von einem Kontaktformular von [_site_title] ([_site_url]) gesendet
-
Known issues:
- "juicer error" when using on localhost instead of configured domain or pseduo-public domain like browserstack's bs-local.com
- expired API connection prevents updating new Instagram content
-
account / API:
- Tina's Google account logs into juicer.io
- which has been connected to her Instagram account while logged in to juicer, Google, and Instagram on the same browser
-
placholder content:
Unless a new page visitor has given consent to loading Instagram data by clicking on "allow once" or "allow always", placeholder images will be shown. This is currently 1 single image, consisting of a cropped region of the 5 latest feed images shown by juicer in the context of the production or preview page.
If the placeholder image does not update automatically, replace the placeholder image by overwriting
/img/instagram/preview5xinsta-20230601.jpg
with an updated image file in the following aspect ratio: width="1857" height="463"
.
To generate a webp alternative on the command line using cwebp:
cwebp /img/instagram/preview5xinsta-20230601.jpg -o /img/instagram/preview5xinsta-20230601.webp
To optimize an existing JPEG file, we can use mozjpeg.
TODO: alternatively in the future:
The update process might be (partially) automated or facilitated using the Screen Capture API.
The site is provided in a German and an English language version using the Polylang plugin. Multilingual content has been described in the content section above. Theme-specific language strings can be created (or updated) using PO(T)/MO files as explained in the WordPress codex:
So we would either
enter the wp_cli
or wordpress
container:
docker-exec -it kleiderordnung-berlin_wordpress_1 bash
then, inside the container, (install and) run wp
cli to create language files:
wp i18n make-pot wp-content/themes/kleiderordnung
or
which is an open source tool for all major OS. If you don’t have the pro version you can easily get the Blank POT by Fx Bénard and use that as the base of your POT file. Once you have placed the blank POT in the languages folder you can click “Update” in Poedit to update the POT file with your strings.
POT files contain the original strings (usually in English, but here German is the default language). "Very long strings" are split into multiple lines (by PoEdit and/or gettext, often starting with an empty string "for better alignment", but that's equivalent to using long one-line strings.
Translation / internationalization (i10n): using po strings: take the .pot
file and translate the msgstr
sections into the respective language(s). The result is a .po
file for each langauge with the same format as a .pot
, but with translations and some specific headers.
Use the official WordPress locale strings (with underscores, not hyphens). For example, the locale for German is de_DE
, and our theme's text domain is kleiderordnung
, therefore the German MO and PO files could be named kleiderordnung-de_DE.mo
and kleiderordnung-de_DE.po
, but that's misleading. When placed inside the theme's language folder (not the WordPress instance's language folder) we must omit the (redundant) theme name (text domain) in the file name.
So we will have:
src/languages/en_US.mo
->themes/kleiderordnung/languages/en_US.mo
src/languages/en_US.po
->themes/kleiderordnung/languages/en_US.po
src/languages/de_DE.mo
->themes/kleiderordnung/languages/de_DE.mo
src/languages/de_DE.po
->themes/kleiderordnung/languages/de_DE.po
This will match the content language in the <html lang
attribute where a hyphen is used instead of the underscore.
<html lang="en-US"
This will also match the necessary explicit loading command in functions.php
:
add_action('after_setup_theme', function() {
load_theme_textdomain( 'kleiderordnung', get_template_directory() . '/languages' );
Always add new text in PHP templates using the translation function and the default language (German) text as the key, e.g.
<?php _e( 'Seite bearbeiten', 'kleiderordnung' ) ?>
Localization in JavaScript needs an additional registration in the localization array in /inc/functions/localize-js.php
to become available in the translations object, e.g. kleiderordnung_translations['über']
. Any string used in JS must be defined both in the .pot
files and in localize-js.php
!
Regenerate the theme's .pot
file or append the new text strings manually to all .pot
and .po
files, e.g.
#: inc/structure/admin-edit-link.php:11
msgid "Seite bearbeiten"
msgstr "Edit Page"
Set PO-Revision-Date to the current date and time in the .po
files and update the Last-Translator (using your name or WordPress user handle) if necessary.
"PO-Revision-Date: 2023-08-19 14:20+0200\n"
"Last-Translator: @openmindculture\n"
Generate / update .mo
files from .po
files: the program msgfmt
(part of the gettext
package) can be used on the command line to create the MO file. A typical msgfmt
command looks like this:
msgfmt -o src/languages/en_US.mo src/languages/en_US.po
msgfmt -o src/languages/de_DE.mo src/languages/de_DE.po
You can use the prepared build target build:translations
to update the .mo
files.
npm run build:translations
Sources:
- https://developer.wordpress.org/apis/internationalization/localization/
- https://polylang.pro/doc/theme-internationalization-and-localization/
- https://polylang.pro/doc/import-and-export-strings-translations/
- izimobil/polib#96
Polylang settings offer automatic redirection to page content based on browser language and preventing caching the front page. As we absolutely want to cache static content to prevent costly and redundant recreation of content on the server side and speed up loading time for clients, this option must not be used!
Automatic detection and redirection can be done alternatively using client-side JavaScript or relying on matching search engine results etc.
Edit source code in src
files, rebuild and test the theme using npm run build
and npm run build:zip
, then start http://localhost:1234/
Increase theme versions in
src/style.css
:Version:
,src/functions.php
:define( 'KLEIDERORDNUNG_THEME_VERSION
,package.json
:"version":
The web site's theme, plugin, and example content can be developed and testing using automated screenshot tests, accessibility and performance audits with axe and Lighthouse, and static code quality assistance using eslint, stylelint, SonarLint.
We can use exported (Figma) template graphics instead of screenshots to verify the initial visual expectations.
TODO this should be a build target in package.json
passing an enviroment variable to codecept.
- open
codecept/homepage_test.js
- set
const prepareBaseImages = true;
- run
npm run codeceptjs
- set
const prepareBaseImages = false;
- Look at the images in
codecept/screenshots
and verify their content manually.
As it seems hard to properly unit-test classic WordPress code, frontend testing is very important to prevent errors and side effects when maintaining our theme.
CodeceptJS is used for frontend / end-to-end / regression test automation using click paths to verify nagivation and legal pages like imprint and privacy information. Full page screenshots are compared to their prior base images respectively.
Jasmine is used for unit testing JavaScript. Test coverage is still low due to dependencies and side effects as most code directly interacts with the DOM API. I tried to use jest but it does not support ES(6) module syntax (import/export) properly without using babel as well.
TODO: unit test tutorials advise to refactor our code so that it never interacts with the global window
object directly. Instead, we should inject window
(or rather the part that we are actually interested in) as a parameter into our functions wherever possible. But what about DOM API callbacks like event handling or IntersectionObserver?
PHP unit test coverage is low or nonexistent for a similar reason: most code is mixed with HTML markup and heavily depends on WordPress core and plugin functionality which seems hard to properly abstract into testable code without overengineering away from the software's best practice. If we don't feel likely at all to achieve a high unit test coverage, we could rather invest the same effort in extending our frontend tests to verify the very same aspects like custom page titles, custom post types, correct paths to styles, fonts and libraries etc. Likewise, computed styles also get tested indirectly when comparing full page screenshots in our frontend tests.
WordPress can be run locally using the provided development server setup using Doker and docker-compose, based on wp_template_opinionated by Ingo Steinke.
The theme bundle and client-side CSS will be built (without SCSS) using PostCSS. Supported upcoming CSS features include imports, custom properties, and native nesting.
JavaScript imports are handled by esbuild
. There is currently no transpilation, so be conservative when adding client-side JS. Instead of TypeScript, there are JSDoc annotations.
Unit tests are few, due to necessary DOM side effects, global window properties, and the focus on visual web design.
Code style follows modern recommendations for HTML, CSS, PHP, and more backwards compatible ones for ECMAScript, ignoring the divergent WordPress and Gutenberg community guidelines.
Note that this is a classic WordPress theme, despite block editor support for pages other than the frontpage, so there is no theme.json but style.css.
The local development setup has been tested on Ubuntu Linux and should work on Windows and MacOS as well. If it doesn't, and the exported CSS and theme files are modified, or if any upcoming WordPress editing features override theme declarations or template parts, those changes should be ported back to the theme source code immediately!
Create a new user as an author or a more appropriate WordPress admin role) (permission group).
Log in to your Dashboard as the new user. On the screen showing the "Site Health" widget, click the "Screen Options" ("Ansicht anpassen") menu item at the top right of your screen and move the custom theme widget to the top position. You can turn off "Site Health" and any other widgets you don't want to see.
This is a per-user function, therefore turning it off only removes it for you and not other users.
If the content has been prepared or edited before adding the new user, make sure that they are able to edit their website content! This could be done by changing ownership and transferring all existing content to the new author (e.g. by using QuickEdit on the overview page).
Don't give the website owner administration rights unless they are a technically experienced person who know how to use WordPress without accidentally destroying, deleting, or misplacing anything.
- Advanced Custom Fields
mu-plugins/kleiderordnung-content-type
for custom fields and content types
- Akismet Anti-Spam Protection (production site only)
- Complianz | GDPR/CCPA Cookie Consent (if necessary)
- Contact Form 7
- Display Featured Image In Post List
- Flamingo (save contact forms messages)
- Contact Form Inbox Report Mailer
- Incompatibility Status
- Juicer Instagram Feed
- Matomo Analytics (production site only)
- Polylang
- SVG Support
- TinyPNG
- Updraft Plus (backup)
- W3 Total Cache (production site only)
- Yoast SEO (for sitemaps etc. - preview / production site only)
- Kleiderordnung (
kleiderordnung
)
- Lottie Player